symbio 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +201 -3
  4. package/bin/symbio.js +217 -0
  5. package/dist/server/api.js +611 -0
  6. package/dist/server/assets.js +154 -0
  7. package/dist/server/bus.js +9 -0
  8. package/dist/server/connections.js +227 -0
  9. package/dist/server/costs.js +175 -0
  10. package/dist/server/docs.js +97 -0
  11. package/dist/server/folder-picker.js +121 -0
  12. package/dist/server/git.js +38 -0
  13. package/dist/server/guard.js +135 -0
  14. package/dist/server/housekeeping.js +119 -0
  15. package/dist/server/index.js +133 -0
  16. package/dist/server/inspect.js +136 -0
  17. package/dist/server/mcp.js +74 -0
  18. package/dist/server/opencode/client.js +152 -0
  19. package/dist/server/opencode/runtime.js +326 -0
  20. package/dist/server/origin.js +15 -0
  21. package/dist/server/plan.js +162 -0
  22. package/dist/server/prompts.js +265 -0
  23. package/dist/server/providers.js +131 -0
  24. package/dist/server/remote.js +271 -0
  25. package/dist/server/secrets.js +105 -0
  26. package/dist/server/ssh.js +146 -0
  27. package/dist/server/store.js +167 -0
  28. package/dist/server/team.js +1440 -0
  29. package/dist/server/tools.js +409 -0
  30. package/dist/server/ui-token.js +29 -0
  31. package/dist/server/updates.js +55 -0
  32. package/dist/server/workspaces.js +133 -0
  33. package/dist/web/assets/index-Cbd1sDvV.css +1 -0
  34. package/dist/web/assets/index-DrIslUUT.js +52 -0
  35. package/dist/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  36. package/dist/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  37. package/dist/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  38. package/dist/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  39. package/dist/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  40. package/dist/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  41. package/dist/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  42. package/dist/web/index.html +14 -0
  43. package/package.json +73 -6
@@ -0,0 +1,611 @@
1
+ // HTTP API for the browser UI. JSON over REST, live updates over SSE.
2
+ import { Hono } from "hono";
3
+ import { streamSSE } from "hono/streaming";
4
+ import { bus } from "./bus.js";
5
+ import { promises as fs } from "node:fs";
6
+ import path from "node:path";
7
+ import { describeInspection, inspectFolder } from "./inspect.js";
8
+ import { gitAvailable, initRepository } from "./git.js";
9
+ import { pickFolder } from "./folder-picker.js";
10
+ import { beginOauth, cancelOauth, completeOauth, connectWithKey, listProviders, oauthStatus, removeLogin } from "./providers.js";
11
+ import { docsRoot, listDocs, readDoc, writeDoc } from "./docs.js";
12
+ import { PriceBook, projectUsage, resolvePrices } from "./costs.js";
13
+ import { globalDir } from "./store.js";
14
+ // Debug routes pass OpenCode responses through, which can contain provider keys.
15
+ const DEBUG = process.env.SYMBIO_DEBUG === "1";
16
+ export function createApi(store, runtime, team, connections) {
17
+ const api = new Hono();
18
+ // ---- usage and prices (estimated cost = tokens × the price per model)
19
+ const priceBook = PriceBook.at(globalDir);
20
+ const priceBookLoaded = priceBook.load();
21
+ let catalog = null;
22
+ const catalogModels = async () => {
23
+ if (!runtime.client)
24
+ return catalog?.models ?? [];
25
+ if (!catalog || Date.now() - catalog.at > 10 * 60_000)
26
+ catalog = { at: Date.now(), models: await runtime.client.listModels() };
27
+ return catalog.models;
28
+ };
29
+ const pricesFor = async (refs) => {
30
+ await priceBookLoaded;
31
+ return resolvePrices([...new Set(refs)], priceBook.prices, await catalogModels().catch(() => []));
32
+ };
33
+ const modelsUsed = (projects) => projects.flatMap((p) => [...p.allowedModels, ...p.agents.flatMap((a) => [a.model, ...Object.keys(a.usageByModel ?? {})])]);
34
+ api.get("/prices", async (c) => {
35
+ await priceBookLoaded;
36
+ return c.json(await pricesFor([...modelsUsed(store.list()), ...Object.keys(priceBook.prices)]));
37
+ });
38
+ api.put("/prices", async (c) => {
39
+ const body = await c.req.json();
40
+ if (!body.model || !/^[\w.-]+\/[\w./:-]+$/.test(body.model))
41
+ return c.json({ error: "model is required (provider/model)" }, 400);
42
+ const p = body.price;
43
+ if (p && ![p.input, p.output, p.cacheRead, p.cacheWrite].every((v) => typeof v === "number" && v >= 0 && v < 10_000)) {
44
+ return c.json({ error: "Prices must be numbers (USD per million tokens)." }, 400);
45
+ }
46
+ await priceBookLoaded;
47
+ await priceBook.set(body.model, p ?? null);
48
+ bus.publish({ type: "prices.updated" });
49
+ return c.json({ ok: true });
50
+ });
51
+ api.get("/projects/:id/usage", async (c) => {
52
+ const project = withProject(c.req.param("id"));
53
+ return c.json(projectUsage(project, await pricesFor(modelsUsed([project]))));
54
+ });
55
+ // ---- global connections (secrets are write-only: never returned)
56
+ api.get("/connections", async (c) => c.json(await connections.views()));
57
+ api.post("/connections", async (c) => {
58
+ try {
59
+ const conn = await connections.create(await c.req.json());
60
+ return c.json(await connections.view(conn), 201);
61
+ }
62
+ catch (err) {
63
+ throw new HttpError(400, err.message);
64
+ }
65
+ });
66
+ api.patch("/connections/:id", async (c) => {
67
+ try {
68
+ const conn = await connections.update(c.req.param("id"), await c.req.json());
69
+ return c.json(await connections.view(conn));
70
+ }
71
+ catch (err) {
72
+ throw new HttpError(400, err.message);
73
+ }
74
+ });
75
+ api.delete("/connections/:id", async (c) => {
76
+ const id = c.req.param("id");
77
+ await connections.remove(id);
78
+ // Projects lose access to it.
79
+ for (const project of store.list().filter((p) => p.connections.some((l) => l.id === id))) {
80
+ project.connections = project.connections.filter((l) => l.id !== id);
81
+ await store.update(project);
82
+ for (const agent of project.agents.filter((a) => a.status !== "stopped"))
83
+ await team.refreshInstructions(project, agent).catch(() => { });
84
+ bus.publish({ type: "project.updated", projectId: project.id });
85
+ }
86
+ return c.json({ ok: true });
87
+ });
88
+ const cleanLinks = (links) => (links ?? [])
89
+ .filter((l) => connections.get(l.id))
90
+ .map((l) => ({ id: l.id, policy: l.policy === "allow" ? "allow" : "ask" }));
91
+ api.post("/connections/:id/test", async (c) => c.json(await connections.test(c.req.param("id"))));
92
+ const runtimeInfo = () => ({
93
+ state: runtime.state,
94
+ version: runtime.version,
95
+ error: runtime.lastError,
96
+ restarts: runtime.restarts,
97
+ memoryMB: runtime.lastMemory ? Math.round(runtime.lastMemory / 1024 / 1024) : null,
98
+ });
99
+ api.get("/runtime", (c) => c.json(runtimeInfo()));
100
+ api.post("/runtime/restart", async (c) => {
101
+ await runtime.restart();
102
+ return c.json(runtimeInfo());
103
+ });
104
+ api.get("/models", async (c) => {
105
+ if (!runtime.client)
106
+ return c.json({ error: "OpenCode is not running" }, 503);
107
+ const models = await runtime.client.listModels();
108
+ if (c.req.query("raw") && DEBUG)
109
+ return c.json(models);
110
+ return c.json(models.map((m) => ({
111
+ ref: `${m.providerID}/${m.id}`,
112
+ providerID: m.providerID,
113
+ id: m.id,
114
+ name: m.name ?? m.id,
115
+ vision: modelSupportsImages(m),
116
+ free: isFree(m),
117
+ })));
118
+ });
119
+ // ---- Model providers (connected to OpenCode; keys are passed on, never stored or returned)
120
+ const oc = () => {
121
+ if (!runtime.client)
122
+ throw new Error("OpenCode is not running");
123
+ return runtime.client;
124
+ };
125
+ const fail = (c, err) => c.json({ error: err.message }, 400);
126
+ api.get("/providers", async (c) => {
127
+ try {
128
+ const { connected, catalog } = await listProviders(oc());
129
+ const projects = store.list();
130
+ return c.json({
131
+ connected: connected.map((p) => ({
132
+ ...p,
133
+ usedBy: projects.filter((x) => x.allowedModels.some((m) => m.startsWith(`${p.id}/`))).map((x) => x.name),
134
+ })),
135
+ catalog,
136
+ });
137
+ }
138
+ catch (err) {
139
+ return fail(c, err);
140
+ }
141
+ });
142
+ api.post("/providers/:id/key", async (c) => {
143
+ const body = await c.req.json();
144
+ try {
145
+ await connectWithKey(oc(), c.req.param("id"), body.key ?? "", body.answer ?? {});
146
+ return c.json({ ok: true });
147
+ }
148
+ catch (err) {
149
+ return fail(c, err);
150
+ }
151
+ });
152
+ api.post("/providers/:id/oauth", async (c) => {
153
+ const body = await c.req.json();
154
+ try {
155
+ return c.json(await beginOauth(oc(), c.req.param("id"), body.methodID, body.answer ?? {}));
156
+ }
157
+ catch (err) {
158
+ return fail(c, err);
159
+ }
160
+ });
161
+ api.get("/providers/:id/oauth/:attemptId", async (c) => {
162
+ try {
163
+ return c.json(await oauthStatus(oc(), c.req.param("id"), c.req.param("attemptId")));
164
+ }
165
+ catch (err) {
166
+ return fail(c, err);
167
+ }
168
+ });
169
+ api.post("/providers/:id/oauth/:attemptId/complete", async (c) => {
170
+ const body = await c.req.json().catch(() => ({}));
171
+ try {
172
+ await completeOauth(oc(), c.req.param("id"), c.req.param("attemptId"), body.code?.trim() || null);
173
+ return c.json({ ok: true });
174
+ }
175
+ catch (err) {
176
+ return fail(c, err);
177
+ }
178
+ });
179
+ api.delete("/providers/:id/oauth/:attemptId", async (c) => {
180
+ await cancelOauth(oc(), c.req.param("id"), c.req.param("attemptId"));
181
+ return c.json({ ok: true });
182
+ });
183
+ api.delete("/providers/logins/:loginId", async (c) => {
184
+ try {
185
+ await removeLogin(oc(), c.req.param("loginId"));
186
+ return c.json({ ok: true });
187
+ }
188
+ catch (err) {
189
+ return fail(c, err);
190
+ }
191
+ });
192
+ // Read-only passthrough to the OpenCode API, for debugging. Off unless SYMBIO_DEBUG=1:
193
+ // OpenCode responses can contain provider keys.
194
+ api.get("/debug/opencode/*", async (c) => {
195
+ if (!DEBUG)
196
+ return c.json({ error: "Debug routes are off. Start Symbio with SYMBIO_DEBUG=1." }, 404);
197
+ if (!runtime.client)
198
+ return c.json({ error: "OpenCode is not running" }, 503);
199
+ const url = new URL(c.req.url);
200
+ const target = url.pathname.replace(/^.*\/debug\/opencode/, "") + url.search;
201
+ return c.json(await runtime.client.request("GET", target));
202
+ });
203
+ api.get("/projects", (c) => c.json(store.list()));
204
+ api.get("/inspect", async (c) => {
205
+ const dir = c.req.query("dir")?.trim();
206
+ if (!dir)
207
+ return c.json({ error: "dir is required" }, 400);
208
+ if (!path.isAbsolute(dir))
209
+ return c.json({ error: "Use a full path, e.g. C:\\Users\\you\\project" }, 400);
210
+ const [inspection, hasGit] = await Promise.all([inspectFolder(dir), gitAvailable()]);
211
+ const existing = store.list().find((p) => path.resolve(p.directory).toLowerCase() === inspection.directory.toLowerCase());
212
+ return c.json({ ...inspection, gitAvailable: hasGit, existingProject: existing ? { id: existing.id, name: existing.name } : null });
213
+ });
214
+ // Opens the native folder dialog on this machine; path is null when cancelled.
215
+ api.post("/pick-folder", async (c) => {
216
+ const body = await c.req.json().catch(() => ({}));
217
+ const initial = body.initial?.trim();
218
+ try {
219
+ return c.json({ path: await pickFolder(initial && path.isAbsolute(initial) ? initial : undefined) });
220
+ }
221
+ catch (err) {
222
+ return c.json({ error: err.message }, 409);
223
+ }
224
+ });
225
+ api.post("/projects", async (c) => {
226
+ const body = await c.req.json();
227
+ if (!body.name?.trim() || !body.directory?.trim())
228
+ return c.json({ error: "name and directory are required" }, 400);
229
+ if (!path.isAbsolute(body.directory.trim()))
230
+ return c.json({ error: "directory must be a full path" }, 400);
231
+ if (!body.coordinatorModel)
232
+ return c.json({ error: "coordinatorModel is required" }, 400);
233
+ const allowed = body.allowedModels?.length ? body.allowedModels : [body.coordinatorModel];
234
+ if (!allowed.includes(body.coordinatorModel))
235
+ allowed.unshift(body.coordinatorModel);
236
+ const directory = body.directory.trim();
237
+ await fs.mkdir(directory, { recursive: true });
238
+ let inspection = await inspectFolder(directory);
239
+ if (body.initGit && !inspection.isGit) {
240
+ await initRepository(directory);
241
+ inspection = { ...inspection, isGit: true };
242
+ }
243
+ const project = await store.create({
244
+ name: body.name.trim(),
245
+ directory,
246
+ allowedModels: allowed,
247
+ coordinatorModel: body.coordinatorModel,
248
+ docsLocation: body.docsLocation === "private" ? "private" : "project",
249
+ origin: inspection.empty ? "new" : "existing",
250
+ inspectionSummary: describeInspection(inspection),
251
+ connections: cleanLinks(body.connections),
252
+ });
253
+ return c.json(project, 201);
254
+ });
255
+ const withProject = (id) => {
256
+ const project = store.get(id);
257
+ if (!project)
258
+ throw new HttpError(404, "project not found");
259
+ return project;
260
+ };
261
+ const withAgent = (project, agentId) => {
262
+ const agent = project.agents.find((a) => a.id === agentId);
263
+ if (!agent)
264
+ throw new HttpError(404, "agent not found");
265
+ return agent;
266
+ };
267
+ api.get("/projects/:id", (c) => c.json(withProject(c.req.param("id"))));
268
+ api.patch("/projects/:id", async (c) => {
269
+ const project = withProject(c.req.param("id"));
270
+ const body = await c.req.json();
271
+ if (body.name?.trim())
272
+ project.name = body.name.trim();
273
+ if (body.renewSessions === "manual" || body.renewSessions === "after-task")
274
+ project.renewSessions = body.renewSessions;
275
+ if (body.messageLimit !== undefined) {
276
+ const limit = Number(body.messageLimit);
277
+ if (!Number.isInteger(limit) || limit < 0 || limit > 1000)
278
+ return c.json({ error: "The message limit must be a whole number from 0 to 1000." }, 400);
279
+ project.messageLimit = limit;
280
+ }
281
+ // Only removals make sense from the UI ("always" answers add entries).
282
+ if (body.allowedPatterns) {
283
+ project.allowedPatterns = project.allowedPatterns.filter((a) => body.allowedPatterns.some((b) => b.action === a.action && b.pattern === a.pattern));
284
+ }
285
+ if (body.connections)
286
+ project.connections = cleanLinks(body.connections);
287
+ const modelsAdded = !!body.allowedModels?.some((m) => !project.allowedModels.includes(m));
288
+ if (body.allowedModels?.length)
289
+ project.allowedModels = body.allowedModels;
290
+ if (body.coordinatorModel) {
291
+ // Switch the live session first so a rejected model doesn't leave the project half-updated.
292
+ const coordinator = project.agents.find((a) => a.role === "coordinator");
293
+ if (coordinator && coordinator.model !== body.coordinatorModel) {
294
+ await team.setModel(project, coordinator, body.coordinatorModel);
295
+ }
296
+ project.coordinatorModel = body.coordinatorModel;
297
+ if (!project.allowedModels.includes(body.coordinatorModel))
298
+ project.allowedModels.unshift(body.coordinatorModel);
299
+ }
300
+ // Agents on a model that is no longer allowed move to core's model.
301
+ for (const agent of project.agents) {
302
+ if (agent.status === "stopped" || project.allowedModels.includes(agent.model))
303
+ continue;
304
+ await team.setModel(project, agent, project.coordinatorModel).catch((err) => console.error(`[models] ${agent.name}:`, err.message));
305
+ }
306
+ await store.update(project);
307
+ bus.publish({ type: "project.updated", projectId: project.id });
308
+ if (body.allowedModels?.length || body.name?.trim() || body.connections) {
309
+ // Briefs mention the project name and allowed models.
310
+ for (const agent of project.agents.filter((a) => a.status !== "stopped")) {
311
+ await team.refreshInstructions(project, agent).catch(() => { });
312
+ }
313
+ }
314
+ // Agents stuck without a working model can resume.
315
+ if (modelsAdded)
316
+ await team.resumeStuck(project, false);
317
+ return c.json(project);
318
+ });
319
+ api.delete("/projects/:id", async (c) => {
320
+ const project = withProject(c.req.param("id"));
321
+ await team.removeProject(project, c.req.query("deleteData") === "true");
322
+ return c.json({ ok: true });
323
+ });
324
+ /** "Retry now" for agents stuck because every model failed (e.g. after a quota reset). */
325
+ api.post("/projects/:id/retry-models", async (c) => {
326
+ const project = withProject(c.req.param("id"));
327
+ return c.json({ resumed: await team.resumeStuck(project, true) });
328
+ });
329
+ api.post("/projects/:id/start", async (c) => {
330
+ const project = withProject(c.req.param("id"));
331
+ const coordinator = await team.ensureCoordinator(project);
332
+ return c.json({ project, coordinator });
333
+ });
334
+ api.get("/projects/:id/docs", async (c) => {
335
+ const project = withProject(c.req.param("id"));
336
+ return c.json({ location: project.docsLocation, root: docsRoot(project), files: await listDocs(project) });
337
+ });
338
+ api.get("/projects/:id/docs/file", async (c) => {
339
+ const project = withProject(c.req.param("id"));
340
+ const rel = c.req.query("path") ?? "";
341
+ try {
342
+ return c.json({ path: rel, content: await readDoc(project, rel) });
343
+ }
344
+ catch (err) {
345
+ if (err.code === "ENOENT")
346
+ throw new HttpError(404, "file not found");
347
+ throw new HttpError(400, err.message);
348
+ }
349
+ });
350
+ api.put("/projects/:id/docs/file", async (c) => {
351
+ const project = withProject(c.req.param("id"));
352
+ const body = await c.req.json();
353
+ if (!body.path || typeof body.content !== "string")
354
+ return c.json({ error: "path and content are required" }, 400);
355
+ try {
356
+ await writeDoc(project, body.path, body.content);
357
+ }
358
+ catch (err) {
359
+ throw new HttpError(400, err.message);
360
+ }
361
+ return c.json({ ok: true });
362
+ });
363
+ api.get("/projects/:id/plan", async (c) => {
364
+ const project = withProject(c.req.param("id"));
365
+ return c.json((await team.plan(project)).plan);
366
+ });
367
+ api.post("/projects/:id/plan/decision", async (c) => {
368
+ const project = withProject(c.req.param("id"));
369
+ const body = await c.req.json();
370
+ if (typeof body.approved !== "boolean")
371
+ return c.json({ error: "approved (true/false) is required" }, 400);
372
+ if (!body.approved && !body.feedback?.trim())
373
+ return c.json({ error: "Say what should change" }, 400);
374
+ try {
375
+ await team.decidePlan(project, body.approved, body.feedback ?? null);
376
+ }
377
+ catch (err) {
378
+ throw new HttpError(409, err.message);
379
+ }
380
+ return c.json((await team.plan(project)).plan);
381
+ });
382
+ api.get("/projects/:id/approvals", async (c) => {
383
+ const project = withProject(c.req.param("id"));
384
+ return c.json(await team.remote.list(project));
385
+ });
386
+ api.post("/projects/:id/approvals/:approvalId", async (c) => {
387
+ const project = withProject(c.req.param("id"));
388
+ const body = await c.req.json();
389
+ if (typeof body.approved !== "boolean")
390
+ return c.json({ error: "approved (true/false) is required" }, 400);
391
+ const approval = (await team.remote.list(project)).find((a) => a.id === c.req.param("approvalId"));
392
+ if (!approval)
393
+ return c.json({ error: "Approval not found" }, 404);
394
+ if (approval.status !== "pending")
395
+ return c.json({ error: `Already ${approval.status}` }, 409);
396
+ const agent = project.agents.find((a) => a.id === approval.agentId && a.status !== "stopped");
397
+ // Runs the action (if approved) in the background — a deploy can take minutes — and
398
+ // delivers the result to the agent as a message.
399
+ void team.remote
400
+ .decide(project, agent, approval.id, body.approved, body.note ?? null)
401
+ .catch((err) => console.error("[approvals]", err.message));
402
+ return c.json({ ok: true });
403
+ });
404
+ api.post("/projects/:id/pause", async (c) => {
405
+ const project = withProject(c.req.param("id"));
406
+ await team.pause(project);
407
+ return c.json(project);
408
+ });
409
+ api.post("/projects/:id/resume", async (c) => {
410
+ const project = withProject(c.req.param("id"));
411
+ await team.resume(project);
412
+ return c.json(project);
413
+ });
414
+ /** Emergency stop: pauses every project that has working agents. */
415
+ api.post("/pause-all", async (c) => {
416
+ const paused = [];
417
+ for (const project of store.list()) {
418
+ if (project.paused || !project.agents.some((a) => a.status === "busy" || a.status === "waiting" || a.status === "stuck"))
419
+ continue;
420
+ await team.pause(project);
421
+ paused.push(project.name);
422
+ }
423
+ return c.json({ paused });
424
+ });
425
+ api.get("/projects/:id/permissions", (c) => {
426
+ const project = withProject(c.req.param("id"));
427
+ return c.json(team.permissionRequests(project));
428
+ });
429
+ api.post("/projects/:id/permissions/:requestId", async (c) => {
430
+ const project = withProject(c.req.param("id"));
431
+ const body = await c.req.json();
432
+ if (body.decision !== "once" && body.decision !== "always" && body.decision !== "reject") {
433
+ return c.json({ error: "decision must be once, always or reject" }, 400);
434
+ }
435
+ try {
436
+ await team.answerPermission(project, c.req.param("requestId"), body.decision, body.note);
437
+ }
438
+ catch (err) {
439
+ throw new HttpError(409, err.message);
440
+ }
441
+ return c.json({ ok: true });
442
+ });
443
+ api.get("/projects/:id/assets", async (c) => {
444
+ const project = withProject(c.req.param("id"));
445
+ const reg = await team.assets(project);
446
+ return c.json(reg.list());
447
+ });
448
+ api.get("/projects/:id/agents/:agentId/forms", async (c) => {
449
+ const project = withProject(c.req.param("id"));
450
+ const agent = withAgent(project, c.req.param("agentId"));
451
+ return c.json(await team.forms(agent));
452
+ });
453
+ api.post("/projects/:id/agents/:agentId/forms/:formId", async (c) => {
454
+ const project = withProject(c.req.param("id"));
455
+ const agent = withAgent(project, c.req.param("agentId"));
456
+ const body = await c.req.json();
457
+ if (!body.answer)
458
+ return c.json({ error: "answer is required" }, 400);
459
+ await team.answerForm(project, agent, c.req.param("formId"), body.answer, body.summary ?? JSON.stringify(body.answer));
460
+ return c.json({ ok: true });
461
+ });
462
+ // Housekeeping: backups, work not on the remote, helpers' disk use.
463
+ api.get("/projects/:id/housekeeping", async (c) => {
464
+ const project = withProject(c.req.param("id"));
465
+ return c.json(team.housekeepingReport(project) ?? (await team.checkProject(project, false)));
466
+ });
467
+ api.post("/projects/:id/housekeeping/check", async (c) => {
468
+ const project = withProject(c.req.param("id"));
469
+ return c.json(await team.checkProject(project, false));
470
+ });
471
+ api.post("/projects/:id/agents/:agentId/clean", async (c) => {
472
+ const project = withProject(c.req.param("id"));
473
+ const agent = withAgent(project, c.req.param("agentId"));
474
+ try {
475
+ return c.json({ result: await team.cleanHelperFolder(project, agent) });
476
+ }
477
+ catch (err) {
478
+ return c.json({ error: err.message }, 400);
479
+ }
480
+ });
481
+ // A fresh session for a long-running agent (keeps its identity; the old session stays readable).
482
+ api.post("/projects/:id/agents/:agentId/renew", async (c) => {
483
+ const project = withProject(c.req.param("id"));
484
+ const agent = withAgent(project, c.req.param("agentId"));
485
+ try {
486
+ await team.renewSession(project, agent, "renewed by the human");
487
+ return c.json(project);
488
+ }
489
+ catch (err) {
490
+ return c.json({ error: err.message }, 400);
491
+ }
492
+ });
493
+ api.post("/projects/:id/agents/:agentId/rename", async (c) => {
494
+ const project = withProject(c.req.param("id"));
495
+ const agent = withAgent(project, c.req.param("agentId"));
496
+ const { name } = await c.req.json();
497
+ try {
498
+ await team.renameAgent(project, agent, name ?? "");
499
+ return c.json(project);
500
+ }
501
+ catch (err) {
502
+ return c.json({ error: err.message }, 400);
503
+ }
504
+ });
505
+ // Switch one agent's model (takes effect from its next turn).
506
+ api.post("/projects/:id/agents/:agentId/model", async (c) => {
507
+ const project = withProject(c.req.param("id"));
508
+ const agent = withAgent(project, c.req.param("agentId"));
509
+ const { model } = await c.req.json();
510
+ if (!model || !project.allowedModels.includes(model))
511
+ return c.json({ error: "Pick one of the project's models." }, 400);
512
+ if (agent.status === "stopped")
513
+ return c.json({ error: `${agent.name} is stopped.` }, 400);
514
+ if (model !== agent.model)
515
+ await team.setModel(project, agent, model);
516
+ return c.json(project);
517
+ });
518
+ api.post("/projects/:id/agents/:agentId/stop", async (c) => {
519
+ const project = withProject(c.req.param("id"));
520
+ const agent = withAgent(project, c.req.param("agentId"));
521
+ if (agent.role === "coordinator")
522
+ return c.json({ error: "The coordinator can't be stopped" }, 400);
523
+ return c.json({ result: await team.stopAgent(project, agent, "stopped by the human") });
524
+ });
525
+ api.post("/projects/:id/agents/:agentId/restart", async (c) => {
526
+ const project = withProject(c.req.param("id"));
527
+ const agent = withAgent(project, c.req.param("agentId"));
528
+ try {
529
+ await team.restartAgent(project, agent);
530
+ return c.json(project);
531
+ }
532
+ catch (err) {
533
+ return c.json({ error: err.message }, 400);
534
+ }
535
+ });
536
+ api.get("/projects/:id/log", async (c) => {
537
+ const project = withProject(c.req.param("id"));
538
+ return c.json(await store.readMessages(project));
539
+ });
540
+ api.get("/projects/:id/agents/:agentId/history", async (c) => {
541
+ const project = withProject(c.req.param("id"));
542
+ const agent = withAgent(project, c.req.param("agentId"));
543
+ // With ?limit: one page ({messages, hasMore}), older pages via ?before=<message id>.
544
+ // Without: the whole conversation as an array (kept for compatibility).
545
+ const limit = Number(c.req.query("limit"));
546
+ if (limit > 0) {
547
+ const before = c.req.query("before");
548
+ if (before && !/^msg_[A-Za-z0-9]+$/.test(before))
549
+ return c.json({ error: "invalid message id" }, 400);
550
+ return c.json(await team.historyPage(agent, Math.min(limit, 200), before, c.req.query("session") || undefined));
551
+ }
552
+ return c.json(await team.history(agent));
553
+ });
554
+ api.post("/projects/:id/agents/:agentId/messages", async (c) => {
555
+ const project = withProject(c.req.param("id"));
556
+ const agent = withAgent(project, c.req.param("agentId"));
557
+ const body = await c.req.json();
558
+ if (!body.text?.trim())
559
+ return c.json({ error: "text is required" }, 400);
560
+ await team.sendFromHuman(project, agent, body.text.trim(), !!body.urgent);
561
+ return c.json({ ok: true });
562
+ });
563
+ api.post("/projects/:id/agents/:agentId/interrupt", async (c) => {
564
+ const project = withProject(c.req.param("id"));
565
+ const agent = withAgent(project, c.req.param("agentId"));
566
+ await team.interrupt(project, agent);
567
+ return c.json({ ok: true });
568
+ });
569
+ api.get("/events", (c) => streamSSE(c, async (stream) => {
570
+ const send = (event) => void stream.writeSSE({ data: JSON.stringify(event) });
571
+ send({ type: "runtime", ...runtimeInfo() });
572
+ bus.on("ui", send);
573
+ const onState = () => send({ type: "runtime", ...runtimeInfo() });
574
+ runtime.on("state", onState);
575
+ stream.onAbort(() => {
576
+ bus.off("ui", send);
577
+ runtime.off("state", onState);
578
+ });
579
+ while (!stream.aborted) {
580
+ await stream.sleep(15_000);
581
+ await stream.writeSSE({ event: "ping", data: "" });
582
+ }
583
+ }));
584
+ const app = new Hono();
585
+ app.onError((err, c) => {
586
+ if (err instanceof HttpError)
587
+ return c.json({ error: err.message }, err.status);
588
+ console.error("[api]", err.message);
589
+ return c.json({ error: err.message }, 500);
590
+ });
591
+ app.route("/", api);
592
+ return app;
593
+ }
594
+ class HttpError extends Error {
595
+ status;
596
+ constructor(status, message) {
597
+ super(message);
598
+ this.status = status;
599
+ }
600
+ }
601
+ function isFree(m) {
602
+ if (/-free$|:free$/.test(m?.id ?? ""))
603
+ return true;
604
+ // cost is a list of pricing tiers; a model is free only if every tier is zero. Missing cost = unknown.
605
+ const tiers = Array.isArray(m?.cost) ? m.cost : m?.cost ? [m.cost] : [];
606
+ return tiers.length > 0 && tiers.every((t) => !t.input && !t.output);
607
+ }
608
+ function modelSupportsImages(m) {
609
+ const input = m?.capabilities?.input;
610
+ return Array.isArray(input) && input.includes("image");
611
+ }