@splinterzzz/ouro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,457 @@
1
+ import express from "express";
2
+ import http from "node:http";
3
+ import { WebSocketServer } from "ws";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { store } from "../lib/store.js";
7
+ import { triage, runAgent, planTicket, executeTicket, getBackendName, setBackendName } from "../lib/agentBackend.js";
8
+ import { createTicketWorktree, diffWorktree } from "../lib/worktree.js";
9
+ import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, telegramTokenVar } from "../lib/config.js";
10
+ import { readEnvVars, writeEnvVars } from "../lib/env.js";
11
+ import { looksLikeToken, maskToken, verifyBotToken } from "../lib/telegram.js";
12
+ import { startService, stopService, serviceStatus, isAlive, tailLog, uptime } from "../lib/daemon.js";
13
+ import { shipTicket } from "../lib/ship.js";
14
+ import * as runs from "../lib/runs.js";
15
+ import {
16
+ listAgents,
17
+ getAgent,
18
+ getAgentRaw,
19
+ saveAgent,
20
+ saveAgentRaw,
21
+ createAgent,
22
+ deleteAgent,
23
+ defaultAgentId,
24
+ agentEvents,
25
+ TOOL_UNIVERSE,
26
+ } from "../lib/agents.js";
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+ // Bundled inside the cli package itself (copied here at build time — see
30
+ // packages/cli's `prepublishOnly` script) so this works whether ouro is
31
+ // running from the monorepo, `npm link`, or a real `npm install` on
32
+ // someone else's machine. It must NOT depend on the dashboard package
33
+ // being present as a sibling folder, since that only exists in this repo.
34
+ const DASHBOARD_DIST = path.resolve(__dirname, "../../dashboard-dist");
35
+
36
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37
+
38
+ /**
39
+ * Everything the Settings screen needs to describe Telegram intake, and
40
+ * nothing it doesn't — the token itself never leaves this process. This API has
41
+ * no auth, so a masked hint is the most a GET is allowed to give up.
42
+ */
43
+ function telegramStatus() {
44
+ const config = readConfig();
45
+ const { name: tokenVar, error: configError } = telegramTokenVar();
46
+ const token = process.env[tokenVar];
47
+ const listen = serviceStatus("listen");
48
+
49
+ return {
50
+ tokenVar,
51
+ // A hand-edited config.json that put a token where a var name goes. The
52
+ // screen has to say so: the token is exposed, and nothing else here would
53
+ // explain why intake is off.
54
+ configError: configError ?? null,
55
+ configured: Boolean(token),
56
+ tokenHint: token ? maskToken(token) : null,
57
+ // Only a token in `.ouro/.env` survives a reboot; a shell export dies with
58
+ // the terminal that started the daemon. The UI has to be able to say which
59
+ // of the two you have, or "configured" is a promise it can't keep.
60
+ persisted: Boolean(readEnvVars()[tokenVar]),
61
+ // Whoever the token last verified as. Stale if someone hand-edits .env —
62
+ // "Test connection" is what re-grounds it.
63
+ bot: config.telegram?.bot ?? null,
64
+ listener: {
65
+ running: Boolean(listen.running),
66
+ pid: listen.running ? listen.pid : null,
67
+ uptime: listen.running ? uptime(listen.startedAt) : null,
68
+ },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Restarts the Telegram intake service so a token pasted into the dashboard
74
+ * takes effect without a trip back to the terminal. `listen` reads its token
75
+ * once at startup — there's nothing to hot-reload, so the restart *is* the
76
+ * mechanism.
77
+ *
78
+ * The child goes through lib/daemon.js like any other, so it lands in the same
79
+ * pid file `ouro status` reads and `ouro stop` kills. A listener started from
80
+ * here is not a second, invisible kind of process.
81
+ */
82
+ async function restartListener() {
83
+ await stopService("listen");
84
+ const record = startService("listen");
85
+
86
+ // listen.js validates the token with getMe() and exits non-zero if Telegram
87
+ // rejects it, so "still alive a beat later" is a real signal — the same check
88
+ // `ouro start` makes. Skip it and we'd report "running" for a process that
89
+ // died half a second after we spawned it.
90
+ await sleep(3000);
91
+ if (isAlive(record.pid)) return { ok: true, pid: record.pid };
92
+ return { ok: false, log: tailLog("listen", 8, record.logOffset) };
93
+ }
94
+
95
+ export function createServer() {
96
+ const app = express();
97
+ app.use(express.json({ limit: "1mb" }));
98
+
99
+ const server = http.createServer(app);
100
+ const wss = new WebSocketServer({ server, path: "/ws" });
101
+
102
+ // An attached WebSocketServer re-emits the http server's listen errors. With
103
+ // no listener here, EADDRINUSE becomes an uncaught exception that kills the
104
+ // process with a stack trace before dashboardCommand's `server.on("error")`
105
+ // can print the readable "port already in use" message. Defer to that
106
+ // handler for listen errors; surface anything else rather than swallow it.
107
+ wss.on("error", (err) => {
108
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") return;
109
+ console.error("[ws] server error:", err.message);
110
+ });
111
+
112
+ function broadcast(payload) {
113
+ const msg = JSON.stringify(payload);
114
+ wss.clients.forEach((client) => {
115
+ if (client.readyState === 1) client.send(msg);
116
+ });
117
+ }
118
+
119
+ store.on("change", broadcast);
120
+ // Agent files are edited from the dashboard but also, deliberately, straight
121
+ // on disk — both paths funnel through agentEvents so every connected client
122
+ // repaints either way.
123
+ agentEvents.on("change", broadcast);
124
+
125
+ /** Resolves which agent .md drives a ticket: explicit assignment, else default. */
126
+ function resolveAgent(ticket) {
127
+ const id = ticket.agentId ?? defaultAgentId();
128
+ return id ? getAgent(id) : null;
129
+ }
130
+
131
+ /** A ticket's own mode wins; otherwise the board-wide default from config. */
132
+ function resolveMode(ticket) {
133
+ return ticket.mode ?? getDefaultMode();
134
+ }
135
+
136
+ // --- tickets ---
137
+
138
+ app.get("/api/tickets", (_req, res) => {
139
+ res.json(store.list());
140
+ });
141
+
142
+ app.post("/api/tickets", (req, res) => {
143
+ const { title, body, source, agentId, mode, priority, summary } = req.body;
144
+ if (!title?.trim()) return res.status(400).json({ error: "title is required" });
145
+ res.json(store.create({ title: title.trim(), body: body ?? "", source, agentId, mode, priority, summary }));
146
+ });
147
+
148
+ app.post("/api/tickets/:id/triage", async (req, res) => {
149
+ const ticket = store.get(req.params.id);
150
+ if (!ticket) return res.status(404).json({ error: "not found" });
151
+
152
+ res.json({ started: true });
153
+
154
+ try {
155
+ const result = await triage({
156
+ prompt: `Analyze this ticket and summarize it, estimate priority, and list files likely affected.\nTitle: ${ticket.title}\nBody: ${ticket.body}`,
157
+ cwd: process.cwd(),
158
+ });
159
+ store.update(ticket.id, {
160
+ status: "triaged",
161
+ summary: result.summary,
162
+ priority: result.priority,
163
+ });
164
+ } catch (err) {
165
+ store.appendLog(ticket.id, { type: "error", text: String(err) });
166
+ }
167
+ });
168
+
169
+ app.post("/api/tickets/:id/mode", (req, res) => {
170
+ const { mode } = req.body; // "agent" | "human" | null (inherit board default)
171
+ if (mode !== null && mode !== "agent" && mode !== "human") {
172
+ return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
173
+ }
174
+ const ticket = store.update(req.params.id, { mode });
175
+ if (!ticket) return res.status(404).json({ error: "not found" });
176
+ res.json(ticket);
177
+ });
178
+
179
+ app.post("/api/tickets/:id/agent", (req, res) => {
180
+ const { agentId } = req.body;
181
+ if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
182
+ const ticket = store.update(req.params.id, { agentId: agentId ?? null });
183
+ if (!ticket) return res.status(404).json({ error: "not found" });
184
+ res.json(ticket);
185
+ });
186
+
187
+ app.post("/api/tickets/:id/run", async (req, res) => {
188
+ const ticket = store.get(req.params.id);
189
+ if (!ticket) return res.status(404).json({ error: "not found" });
190
+ if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
191
+
192
+ const mode = resolveMode(ticket);
193
+ const agent = resolveAgent(ticket);
194
+
195
+ let signal;
196
+ try {
197
+ signal = runs.begin(ticket.id, mode);
198
+ } catch (err) {
199
+ return res.status(409).json({ error: String(err.message || err) });
200
+ }
201
+
202
+ res.json({ started: true, mode, agentId: agent?.id ?? null });
203
+ store.update(ticket.id, { status: "in_progress", mode, agentId: agent?.id ?? ticket.agentId, cancelReason: null });
204
+
205
+ const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
206
+
207
+ try {
208
+ const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticket.id);
209
+ store.update(ticket.id, { worktree: worktreeDir, branch, baseBranch: base });
210
+
211
+ const prompt = `Ticket: ${ticket.title}\n\n${ticket.body}\n\nImplement this and run relevant tests.`;
212
+
213
+ if (mode === "agent") {
214
+ // Full autonomy, single call.
215
+ const result = await runAgent({ prompt, cwd: worktreeDir, onEvent, signal, agent });
216
+ if (result.aborted) return; // cancel route already marked it
217
+ const diff = await diffWorktree(ticket.id).catch(() => null);
218
+ store.update(ticket.id, { status: "review", diff, sessionId: result.sessionId, awaitingApproval: false });
219
+ // Agent mode is the no-pauses path: an unpushed branch in a local
220
+ // worktree isn't a finished ticket, so carry it through to a PR.
221
+ if (getAutoShip()) await shipTicket(ticket.id);
222
+ } else {
223
+ // Human-in-the-loop: plan only, no writes yet. The card shows the
224
+ // plan and waits for an explicit Approve before phase 2 runs.
225
+ const result = await planTicket({ prompt, cwd: worktreeDir, onEvent, signal, agent });
226
+ if (result.aborted) return;
227
+ store.update(ticket.id, {
228
+ status: "review",
229
+ sessionId: result.sessionId,
230
+ plan: result.lastMessage,
231
+ awaitingApproval: true,
232
+ });
233
+ }
234
+ } catch (err) {
235
+ store.appendLog(ticket.id, { type: "error", text: String(err.message || err) });
236
+ store.cancel(ticket.id, `Run failed: ${err.message || err}`);
237
+ } finally {
238
+ runs.end(ticket.id);
239
+ }
240
+ });
241
+
242
+ app.post("/api/tickets/:id/approve", async (req, res) => {
243
+ const ticket = store.get(req.params.id);
244
+ if (!ticket) return res.status(404).json({ error: "not found" });
245
+ if (!ticket.awaitingApproval) return res.status(409).json({ error: "ticket is not awaiting approval" });
246
+ if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
247
+
248
+ let signal;
249
+ try {
250
+ signal = runs.begin(ticket.id, "execute");
251
+ } catch (err) {
252
+ return res.status(409).json({ error: String(err.message || err) });
253
+ }
254
+
255
+ res.json({ started: true });
256
+ store.update(ticket.id, { status: "in_progress", awaitingApproval: false });
257
+
258
+ const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
259
+
260
+ try {
261
+ const result = await executeTicket({
262
+ cwd: ticket.worktree,
263
+ sessionId: ticket.sessionId,
264
+ onEvent,
265
+ signal,
266
+ agent: resolveAgent(ticket),
267
+ });
268
+ if (result.aborted) return;
269
+ const diff = await diffWorktree(ticket.id).catch(() => null);
270
+ store.update(ticket.id, { status: "review", diff, sessionId: result.sessionId });
271
+ // You already approved the plan — the PR is the point of that approval,
272
+ // so don't stop one step short of it.
273
+ if (getAutoShip()) await shipTicket(ticket.id);
274
+ } catch (err) {
275
+ store.appendLog(ticket.id, { type: "error", text: String(err.message || err) });
276
+ store.cancel(ticket.id, `Execute failed: ${err.message || err}`);
277
+ } finally {
278
+ runs.end(ticket.id);
279
+ }
280
+ });
281
+
282
+ // Manual ship — the button on a Review card, and the retry path when
283
+ // autoShip is off or a push/PR failed the first time.
284
+ app.post("/api/tickets/:id/ship", async (req, res) => {
285
+ const ticket = store.get(req.params.id);
286
+ if (!ticket) return res.status(404).json({ error: "not found" });
287
+ if (!ticket.worktree) return res.status(409).json({ error: "this ticket has never run — nothing to ship" });
288
+ if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "still running" });
289
+
290
+ const result = await shipTicket(ticket.id);
291
+ if (!result.ok && result.error) return res.status(422).json(result);
292
+ res.json(result);
293
+ });
294
+
295
+ app.post("/api/tickets/:id/cancel", (req, res) => {
296
+ const ticket = store.get(req.params.id);
297
+ if (!ticket) return res.status(404).json({ error: "not found" });
298
+
299
+ // Cancelling a queued/awaiting ticket is just as valid as killing a live
300
+ // run — there may be no child process to signal, and that's not an error.
301
+ const killed = runs.cancel(ticket.id);
302
+ const reason = req.body?.reason ?? (killed ? "Run cancelled from the dashboard." : "Cancelled from the dashboard.");
303
+ store.appendLog(ticket.id, { type: "cancelled", text: reason });
304
+ res.json(store.cancel(ticket.id, reason));
305
+ });
306
+
307
+ app.post("/api/tickets/:id/reopen", (req, res) => {
308
+ const ticket = store.reopen(req.params.id);
309
+ if (!ticket) return res.status(404).json({ error: "not found" });
310
+ res.json(ticket);
311
+ });
312
+
313
+ app.delete("/api/tickets/:id", (req, res) => {
314
+ runs.cancel(req.params.id); // don't leave an orphaned child behind
315
+ if (!store.remove(req.params.id)) return res.status(404).json({ error: "not found" });
316
+ res.json({ deleted: true });
317
+ });
318
+
319
+ app.get("/api/runs", (_req, res) => {
320
+ res.json(runs.activeRuns());
321
+ });
322
+
323
+ // --- agents (backed by .ouro/agents/*.md) ---
324
+
325
+ app.get("/api/agents", (_req, res) => {
326
+ res.json({ agents: listAgents(), toolUniverse: TOOL_UNIVERSE, defaultAgentId: defaultAgentId() });
327
+ });
328
+
329
+ app.post("/api/agents", (req, res) => {
330
+ const { name } = req.body;
331
+ if (!name?.trim()) return res.status(400).json({ error: "name is required" });
332
+ res.json(createAgent({ name: name.trim() }));
333
+ });
334
+
335
+ app.get("/api/agents/:id", (req, res) => {
336
+ const agent = getAgent(req.params.id);
337
+ if (!agent) return res.status(404).json({ error: "not found" });
338
+ res.json({ ...agent, raw: getAgentRaw(req.params.id) });
339
+ });
340
+
341
+ app.patch("/api/agents/:id", (req, res) => {
342
+ try {
343
+ // `raw` replaces the whole file; anything else is a structured field
344
+ // patch. The dashboard offers both, so the route accepts both.
345
+ const agent = req.body.raw !== undefined
346
+ ? saveAgentRaw(req.params.id, req.body.raw)
347
+ : saveAgent(req.params.id, req.body);
348
+ if (!agent) return res.status(404).json({ error: "not found" });
349
+ res.json({ ...agent, raw: getAgentRaw(req.params.id) });
350
+ } catch (err) {
351
+ res.status(400).json({ error: String(err.message || err) });
352
+ }
353
+ });
354
+
355
+ app.delete("/api/agents/:id", (req, res) => {
356
+ if (!deleteAgent(req.params.id)) return res.status(404).json({ error: "not found" });
357
+ res.json({ deleted: true });
358
+ });
359
+
360
+ // --- config (backend + default mode) ---
361
+
362
+ app.get("/api/config", (_req, res) => {
363
+ const config = readConfig();
364
+ // `pid` is here so `ouro start` can tell *this* server apart from another
365
+ // ouro already holding the port. Without it, a probe that gets a 200 only
366
+ // proves someone is listening — not that the process we just spawned is
367
+ // the one answering.
368
+ res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), pid: process.pid, config });
369
+ });
370
+
371
+ app.post("/api/config/backend", (req, res) => {
372
+ try {
373
+ res.json({ backend: setBackendName(req.body.backend) });
374
+ } catch (err) {
375
+ res.status(400).json({ error: String(err.message || err) });
376
+ }
377
+ });
378
+
379
+ app.post("/api/config/mode", (req, res) => {
380
+ try {
381
+ const mode = setDefaultMode(req.body.mode);
382
+ broadcast({ type: "config", defaultMode: mode });
383
+ res.json({ defaultMode: mode });
384
+ } catch (err) {
385
+ res.status(400).json({ error: String(err.message || err) });
386
+ }
387
+ });
388
+
389
+ // --- telegram credentials ---
390
+ //
391
+ // Pasting a token here does what the README used to ask you to do by hand:
392
+ // write `.ouro/.env`, then `ouro restart`. Both steps are the same ones the
393
+ // CLI takes (lib/env.js, lib/daemon.js), so a token saved from the dashboard
394
+ // and one echoed into the file are indistinguishable afterwards — this screen
395
+ // is a front end for that file, not a second place credentials can live.
396
+
397
+ app.get("/api/config/telegram", (_req, res) => {
398
+ res.json(telegramStatus());
399
+ });
400
+
401
+ app.post("/api/config/telegram", async (req, res) => {
402
+ const token = String(req.body?.botToken ?? "").trim();
403
+ if (!token) return res.status(400).json({ error: "botToken is required" });
404
+ if (!looksLikeToken(token)) {
405
+ return res.status(400).json({
406
+ error: "That doesn't look like a bot token. @BotFather issues them as 123456789:AA… — a bot id, a colon, then a 35-character secret.",
407
+ });
408
+ }
409
+
410
+ // Verify before writing. A token that only fails later fails inside a
411
+ // detached background process, where the evidence is a log file nobody is
412
+ // tailing — so the round trip to Telegram happens while someone is still
413
+ // looking at the screen that caused it.
414
+ const check = await verifyBotToken(token);
415
+ if (!check.ok) return res.status(422).json({ error: check.error });
416
+
417
+ const { name: tokenVar } = telegramTokenVar();
418
+ writeEnvVars({ [tokenVar]: token });
419
+ // Shallow-merge by hand: writeConfig replaces top-level keys, so patching
420
+ // `telegram` with a bare { bot } would drop botTokenEnvVar with it.
421
+ writeConfig({ telegram: { ...readConfig().telegram, bot: check.bot } });
422
+
423
+ const restart = await restartListener();
424
+ res.json({ ...telegramStatus(), restart });
425
+ });
426
+
427
+ app.post("/api/config/telegram/test", async (_req, res) => {
428
+ const { name: tokenVar } = telegramTokenVar();
429
+ const token = process.env[tokenVar];
430
+ if (!token) return res.status(409).json({ error: `${tokenVar} isn't set — save a token first.` });
431
+
432
+ const check = await verifyBotToken(token);
433
+ if (!check.ok) return res.status(422).json({ error: check.error });
434
+
435
+ writeConfig({ telegram: { ...readConfig().telegram, bot: check.bot } });
436
+ res.json({ ...telegramStatus(), verified: true });
437
+ });
438
+
439
+ app.delete("/api/config/telegram", async (_req, res) => {
440
+ // Stop first: the listener holds the old token in memory, so leaving it up
441
+ // would mean a bot still answering strangers with a credential the
442
+ // dashboard now says is gone.
443
+ await stopService("listen");
444
+ writeEnvVars({ [telegramTokenVar().name]: null });
445
+ const { bot, ...telegram } = readConfig().telegram ?? {};
446
+ writeConfig({ telegram });
447
+ res.json(telegramStatus());
448
+ });
449
+
450
+ // --- static dashboard ---
451
+ app.use(express.static(DASHBOARD_DIST));
452
+ app.get("*", (_req, res) => {
453
+ res.sendFile(path.join(DASHBOARD_DIST, "index.html"));
454
+ });
455
+
456
+ return server;
457
+ }