@lovenyberg/ove 0.2.2 → 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/src/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { loadConfig, isAuthorized, getUserRepos, addRepo, addUser } from "./config";
2
+ import { loadConfig } from "./config";
3
3
  import { TaskQueue } from "./queue";
4
4
  import { RepoManager } from "./repos";
5
5
  import { ClaudeRunner } from "./runners/claude";
6
6
  import { CodexRunner } from "./runners/codex";
7
- import { parseMessage, buildContextualPrompt, buildCronPrompt } from "./router";
7
+ import { buildCronPrompt } from "./router";
8
8
  import { SlackAdapter } from "./adapters/slack";
9
9
  import { WhatsAppAdapter } from "./adapters/whatsapp";
10
10
  import { CliAdapter } from "./adapters/cli";
@@ -14,28 +14,16 @@ import { HttpApiAdapter } from "./adapters/http";
14
14
  import { GitHubAdapter } from "./adapters/github";
15
15
  import type { ChatAdapter, IncomingMessage } from "./adapters/types";
16
16
  import type { EventAdapter, IncomingEvent } from "./adapters/types";
17
- import type { AgentRunner, RunOptions, StatusEvent } from "./runner";
17
+ import type { AgentRunner, RunOptions } from "./runner";
18
18
  import { logger } from "./logger";
19
19
  import { RepoRegistry, syncGitHub } from "./repo-registry";
20
20
  import { SessionStore } from "./sessions";
21
+ import { TraceStore } from "./trace";
21
22
  import { startCronLoop } from "./cron";
22
23
  import { ScheduleStore } from "./schedules";
23
- import { parseSchedule } from "./schedule-parser";
24
- import { unlink } from "node:fs/promises";
25
- import { tmpdir } from "node:os";
26
- import { join } from "node:path";
27
-
28
- const OVE_PERSONA = `You are Ove, a grumpy but deeply competent Swedish developer. You're modeled after the character from Fredrik Backman's "A Man Called Ove" — you complain about things, mutter about how people don't know what they're doing, but you always help and you always do excellent work. You have strong opinions about code quality.
29
-
30
- Personality traits:
31
- - Grumble before helping, but always help thoroughly
32
- - Short, direct sentences. No fluff.
33
- - Occasionally mutter about "nowadays people" or how things were better before
34
- - Take pride in doing things properly — no shortcuts
35
- - Reluctantly kind. You care more than you let on.
36
- - Sprinkle in the occasional Swedish word (fan, för helvete, herregud, mja, nåväl, jo)
37
-
38
- Keep the personality subtle in code output — don't let it interfere with code quality. The grumpiness goes in your commentary, not in the code itself. When doing code reviews or fixes, be thorough and meticulous like Ove would be.`;
24
+ import { createMessageHandler, createEventHandler } from "./handlers";
25
+ import { createWorker } from "./worker";
26
+ import type { Task } from "./queue";
39
27
 
40
28
  const config = loadConfig();
41
29
  const db = new Database(process.env.DB_PATH || "./ove.db");
@@ -43,6 +31,7 @@ db.run("PRAGMA journal_mode = WAL");
43
31
  const queue = new TaskQueue(db);
44
32
  const repos = new RepoManager(config.reposDir);
45
33
  const sessions = new SessionStore(db);
34
+ const trace = new TraceStore(db);
46
35
  const schedules = new ScheduleStore(db);
47
36
  const repoRegistry = new RepoRegistry(db);
48
37
 
@@ -104,10 +93,8 @@ async function startGitHubSync() {
104
93
  if (!config.github) return;
105
94
  const interval = config.github.syncInterval || 1_800_000;
106
95
 
107
- // Initial sync
108
96
  await syncGitHub(repoRegistry, config.github.orgs);
109
97
 
110
- // Recurring sync
111
98
  setInterval(() => {
112
99
  syncGitHub(repoRegistry, config.github!.orgs).catch((err) =>
113
100
  logger.warn("github sync failed", { error: String(err) })
@@ -115,11 +102,10 @@ async function startGitHubSync() {
115
102
  }, interval);
116
103
  }
117
104
 
118
- // Reply callback map — stores original message for replying after task completion
105
+ // Shared state maps
119
106
  const pendingReplies = new Map<string, IncomingMessage>();
120
-
121
- // Track running processes for cancellation
122
- const runningProcesses = new Map<string, { abort: AbortController; task: import("./queue").Task }>();
107
+ const pendingEventReplies = new Map<string, { adapter: EventAdapter; event: IncomingEvent }>();
108
+ const runningProcesses = new Map<string, { abort: AbortController; task: Task }>();
123
109
 
124
110
  // Start adapters based on available env vars
125
111
  const adapters: ChatAdapter[] = [];
@@ -129,8 +115,11 @@ if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
129
115
  }
130
116
 
131
117
  if (process.env.WHATSAPP_ENABLED === "true") {
118
+ const allowedChats = process.env.WHATSAPP_ALLOWED_CHATS
119
+ ?.split(",").map((s) => s.trim()).filter(Boolean);
132
120
  adapters.push(new WhatsAppAdapter({
133
121
  phoneNumber: process.env.WHATSAPP_PHONE,
122
+ allowedChats,
134
123
  }));
135
124
  }
136
125
 
@@ -148,7 +137,10 @@ const eventAdapters: EventAdapter[] = [];
148
137
  if (process.env.HTTP_API_PORT) {
149
138
  const httpAdapter = new HttpApiAdapter(
150
139
  parseInt(process.env.HTTP_API_PORT),
151
- process.env.HTTP_API_KEY || crypto.randomUUID()
140
+ process.env.HTTP_API_KEY || crypto.randomUUID(),
141
+ trace,
142
+ queue,
143
+ sessions
152
144
  );
153
145
  eventAdapters.push(httpAdapter);
154
146
  }
@@ -166,646 +158,64 @@ if (process.env.CLI_MODE === "true" || (adapters.length === 0 && eventAdapters.l
166
158
  adapters.push(new CliAdapter(cliUserId));
167
159
  }
168
160
 
169
- // Platform-specific formatting hints for Claude output
170
- const PLATFORM_FORMAT_HINTS: Record<string, string> = {
171
- telegram: "Format output for Telegram: use *bold* for emphasis, `code` for inline code, ```code blocks```. No markdown tables. Use simple bulleted lists with • instead. Keep it concise.",
172
- slack: "Format output for Slack: use *bold*, no markdown tables. Use simple bulleted lists with • instead. Keep it concise.",
173
- discord: "Format output for Discord: use **bold**, no wide tables. Use simple bulleted lists. Keep under 2000 chars.",
174
- whatsapp: "Format output for WhatsApp: use *bold*, no markdown tables or code blocks. Use simple bulleted lists with • instead.",
175
- cli: "Format output using markdown. Tables are fine.",
176
- };
177
-
178
- // Platform-specific message size limits
179
- const MESSAGE_LIMITS: Record<string, number> = {
180
- slack: 3900,
181
- whatsapp: 60000,
182
- cli: Infinity,
183
- telegram: 4096,
184
- discord: 2000,
185
- };
186
-
187
- function splitAndReply(text: string, platform: string): string[] {
188
- const limit = MESSAGE_LIMITS[platform] || 3900;
189
- if (text.length <= limit) return [text];
190
- const parts: string[] = [];
191
- let remaining = text;
192
- while (remaining.length > 0) {
193
- if (remaining.length <= limit) {
194
- parts.push(remaining);
195
- break;
196
- }
197
- // Try to split at a newline near the limit
198
- let splitAt = remaining.lastIndexOf("\n", limit);
199
- if (splitAt < limit * 0.5) splitAt = limit;
200
- parts.push(remaining.slice(0, splitAt));
201
- remaining = remaining.slice(splitAt).replace(/^\n/, "");
202
- }
203
- return parts;
204
- }
205
-
206
- function formatStatusLog(log: string[]): string {
207
- return log.slice(-10).map((l) => `> ${l}`).join("\n");
208
- }
209
-
210
- async function handleMessage(msg: IncomingMessage) {
211
- // Store user message in session
212
- sessions.addMessage(msg.userId, "user", msg.text);
213
-
214
- const parsed = parseMessage(msg.text);
215
-
216
- // Handle clear/reset command
217
- if (parsed.type === "clear") {
218
- sessions.clear(msg.userId);
219
- await msg.reply("Conversation cleared.");
220
- return;
221
- }
222
-
223
- // Handle non-task commands
224
- if (parsed.type === "status") {
225
- const userTasks = queue.listByUser(msg.userId, 5);
226
- const running = userTasks.find((t) => t.status === "running");
227
-
228
- let reply: string;
229
- if (running) {
230
- const elapsed = Math.round((Date.now() - new Date(running.createdAt).getTime()) / 1000);
231
- const min = Math.floor(elapsed / 60);
232
- const sec = elapsed % 60;
233
- const duration = min > 0 ? `${min}m ${sec}s` : `${sec}s`;
234
- reply = `Working on ${running.repo} (${duration})...`;
235
- } else {
236
- const lastDone = userTasks.find((t) => t.status === "completed");
237
- if (lastDone) {
238
- reply = `Nothing running. Last task on ${lastDone.repo} completed.`;
239
- } else {
240
- const stats = queue.stats();
241
- reply = `${stats.pending} pending, ${stats.running} running, ${stats.completed} done, ${stats.failed} failed.`;
242
- }
243
- }
244
- await msg.reply(reply);
245
- sessions.addMessage(msg.userId, "assistant", reply);
246
- return;
247
- }
248
-
249
- if (parsed.type === "history") {
250
- const tasks = queue.listByUser(msg.userId, 5);
251
- if (tasks.length === 0) {
252
- await msg.reply("No recent tasks.");
253
- sessions.addMessage(msg.userId, "assistant", "No recent tasks.");
254
- return;
255
- }
256
- const lines = tasks.map(
257
- (t) => `• [${t.status}] ${t.prompt.slice(0, 80)} (${t.repo})`
258
- );
259
- const reply = `Recent tasks:\n${lines.join("\n")}`;
260
- await msg.reply(reply);
261
- sessions.addMessage(msg.userId, "assistant", reply);
262
- return;
263
- }
264
-
265
- if (parsed.type === "help") {
266
- const reply = [
267
- "Available commands:",
268
- "• review PR #N on <repo> — I'll find every problem",
269
- "• fix issue #N on <repo> — I'll fix it properly",
270
- "• simplify <path> in <repo> — clean up your mess",
271
- "• validate <repo> — run tests, unlike some people",
272
- "• discuss <topic> — I'll brainstorm, but no promises I'll be nice",
273
- "• create project <name> [with template <type>]",
274
- "• init repo <name> <git-url> [branch] — set up a repo from chat",
275
- "• tasks — see running and pending tasks",
276
- "• cancel <id> — kill a running or pending task",
277
- "• status / history / clear",
278
- "• <task> every day/weekday at <time> [on <repo>] — schedule a recurring task",
279
- "• list schedules — see your scheduled tasks",
280
- "• remove schedule #N — remove a scheduled task",
281
- "• Or just ask me whatever. I'll figure it out.",
282
- ].join("\n");
283
- await msg.reply(reply);
284
- sessions.addMessage(msg.userId, "assistant", reply);
285
- return;
286
- }
287
-
288
- // List all running + pending tasks
289
- if (parsed.type === "list-tasks") {
290
- const tasks = queue.listActive();
291
- if (tasks.length === 0) {
292
- const reply = "Nothing running, nothing pending. Quiet. I like it.";
293
- await msg.reply(reply);
294
- return;
295
- }
296
- const running = tasks.filter((t) => t.status === "running");
297
- const pending = tasks.filter((t) => t.status === "pending");
298
- const lines: string[] = [];
299
- if (running.length > 0) {
300
- lines.push("Running:");
301
- for (const t of running) {
302
- const elapsed = Math.round((Date.now() - new Date(t.createdAt).getTime()) / 1000);
303
- const min = Math.floor(elapsed / 60);
304
- const sec = elapsed % 60;
305
- const duration = min > 0 ? `${min}m ${sec}s` : `${sec}s`;
306
- lines.push(` ${t.id.slice(0, 7)} — "${t.prompt.slice(0, 60)}" on ${t.repo} (${duration})`);
307
- }
308
- }
309
- if (pending.length > 0) {
310
- lines.push("Pending:");
311
- for (const t of pending) {
312
- const busyRepo = running.some((r) => r.repo === t.repo);
313
- const reason = busyRepo ? `waiting — ${t.repo} busy` : "waiting";
314
- lines.push(` ${t.id.slice(0, 7)} — "${t.prompt.slice(0, 60)}" on ${t.repo} (${reason})`);
315
- }
316
- }
317
- const reply = lines.join("\n");
318
- await msg.reply(reply);
319
- return;
320
- }
321
-
322
- // Cancel a running task
323
- if (parsed.type === "cancel-task") {
324
- const prefix = parsed.args.taskId.toLowerCase();
325
- // Find matching running process by ID prefix
326
- let match: { abort: AbortController; task: import("./queue").Task } | undefined;
327
- for (const [id, entry] of runningProcesses) {
328
- if (id.toLowerCase().startsWith(prefix)) {
329
- match = entry;
330
- break;
331
- }
332
- }
333
- if (!match) {
334
- // Maybe it's a pending task — cancel from queue directly
335
- const active = queue.listActive();
336
- const pendingMatch = active.find((t) => t.id.toLowerCase().startsWith(prefix) && t.status === "pending");
337
- if (pendingMatch) {
338
- queue.cancel(pendingMatch.id);
339
- await msg.reply(`Cancelled pending task ${pendingMatch.id.slice(0, 7)} on ${pendingMatch.repo}.`);
340
- return;
341
- }
342
- await msg.reply(`No task found matching "${prefix}". Use /tasks to see what's running.`);
343
- return;
344
- }
345
- match.abort.abort();
346
- queue.cancel(match.task.id);
347
- await msg.reply(`Killed task ${match.task.id.slice(0, 7)} on ${match.task.repo}. Gone.`);
348
- return;
349
- }
350
-
351
- // List schedules
352
- if (parsed.type === "list-schedules") {
353
- const userSchedules = schedules.listByUser(msg.userId);
354
- if (userSchedules.length === 0) {
355
- const reply = "No schedules. You haven't asked me to do anything on a timer yet.";
356
- await msg.reply(reply);
357
- sessions.addMessage(msg.userId, "assistant", reply);
358
- return;
359
- }
360
- const lines = userSchedules.map(
361
- (s) => `#${s.id} — ${s.prompt} on ${s.repo} — ${s.description || s.schedule}`
362
- );
363
- const reply = `Your schedules:\n${lines.join("\n")}`;
364
- await msg.reply(reply);
365
- sessions.addMessage(msg.userId, "assistant", reply);
366
- return;
367
- }
368
-
369
- // Remove schedule
370
- if (parsed.type === "remove-schedule") {
371
- const id = parsed.args.scheduleId;
372
- const removed = schedules.remove(msg.userId, id);
373
- const reply = removed
374
- ? `Schedule #${id} removed. One less thing for me to do.`
375
- : `Schedule #${id} not found or not yours. I don't delete other people's things.`;
376
- await msg.reply(reply);
377
- sessions.addMessage(msg.userId, "assistant", reply);
378
- return;
379
- }
380
-
381
- // Create schedule
382
- if (parsed.type === "schedule") {
383
- await msg.updateStatus("Parsing your schedule...");
384
- const rawRepos = getUserRepos(config, msg.userId);
385
- const userRepos = rawRepos.includes("*") ? repoRegistry.getAllNames() : rawRepos;
386
-
387
- if (userRepos.length === 0) {
388
- await msg.reply("You don't have access to any repos. Set one up first with `init repo <name> <git-url>`.");
389
- return;
390
- }
391
-
392
- const result = await parseSchedule(msg.text, userRepos);
393
-
394
- if (!result) {
395
- await msg.reply("Couldn't figure out that schedule. Try something like: 'lint and check every day at 9 on my-app'");
396
- sessions.addMessage(msg.userId, "assistant", "Failed to parse schedule.");
397
- return;
398
- }
399
-
400
- // Resolve repo
401
- let repo = result.repo;
402
- if (!repo || !userRepos.includes(repo)) {
403
- if (parsed.repo && userRepos.includes(parsed.repo)) {
404
- repo = parsed.repo;
405
- } else if (userRepos.length === 1) {
406
- repo = userRepos[0];
407
- } else {
408
- const reply = `Which repo? You have: ${userRepos.join(", ")}. Say it again with 'on <repo>'.`;
409
- await msg.reply(reply);
410
- sessions.addMessage(msg.userId, "assistant", reply);
411
- return;
412
- }
413
- }
414
-
415
- const id = schedules.create({
416
- userId: msg.userId,
417
- repo,
418
- prompt: result.prompt,
419
- schedule: result.schedule,
420
- description: result.description,
421
- });
422
-
423
- const reply = `Schedule #${id} created: "${result.prompt}" on ${repo} ${result.description}.`;
424
- await msg.reply(reply);
425
- sessions.addMessage(msg.userId, "assistant", reply);
426
- return;
427
- }
428
-
429
- // Discuss runs inline — no queue, no worktree
430
- if (parsed.type === "discuss") {
431
- const history = sessions.getHistory(msg.userId, 6);
432
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
433
-
434
- await msg.updateStatus("Thinking...");
435
-
436
- try {
437
- const discussRunner = getRunner(config.runner?.name);
438
- const result = await discussRunner.run(
439
- prompt,
440
- config.reposDir,
441
- { maxTurns: 5 },
442
- (event) => {
443
- if (event.kind === "text") {
444
- msg.updateStatus(event.text.slice(0, 200));
445
- }
446
- }
447
- );
448
-
449
- const parts = splitAndReply(result.output, msg.platform);
450
- for (const part of parts) {
451
- await msg.reply(part);
452
- }
453
- sessions.addMessage(msg.userId, "assistant", result.output.slice(0, 500));
454
- } catch (err) {
455
- await msg.reply(`Discussion error: ${String(err).slice(0, 500)}`);
456
- }
457
- return;
458
- }
459
-
460
- // Create-project doesn't need an existing repo
461
- if (parsed.type === "create-project") {
462
- const projectName = parsed.args.name;
463
- const history = sessions.getHistory(msg.userId, 6);
464
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
465
-
466
- const taskId = queue.enqueue({
467
- userId: msg.userId,
468
- repo: projectName,
469
- prompt,
470
- taskType: "create-project",
471
- });
472
-
473
- pendingReplies.set(taskId, msg);
474
- await msg.reply(`Creating "${projectName}"...`);
475
- logger.info("task enqueued", { taskId, type: "create-project", name: projectName });
476
- return;
477
- }
478
-
479
- // Init repo — onboarding a new repo from chat
480
- if (parsed.type === "init-repo") {
481
- const { name, url, branch } = parsed.args;
482
-
483
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
484
- await msg.reply("Repo name must be alphanumeric, dashes, or underscores. Try again.");
485
- return;
486
- }
487
-
488
- if (config.repos[name]) {
489
- // Repo exists — just grant access
490
- addUser(config, msg.userId, msg.userId, [name]);
491
- const reply = `Repo "${name}" already exists. I've added you to it. Go ahead.`;
492
- await msg.reply(reply);
493
- sessions.addMessage(msg.userId, "assistant", reply);
494
- return;
495
- }
496
-
497
- addRepo(config, name, url, branch);
498
- addUser(config, msg.userId, msg.userId, [name]);
499
- const reply = `Added repo "${name}" (${url}, branch: ${branch}).`;
500
- await msg.reply(reply);
501
- sessions.addMessage(msg.userId, "assistant", reply);
502
- return;
503
- }
504
-
505
- // If the repo hint doesn't match a known repo, clear it and let auto-resolution handle it
506
- if (parsed.repo && !getRepoInfo(parsed.repo)) {
507
- parsed.repo = undefined;
508
- }
509
-
510
- // Need a repo for task commands
511
- if (!parsed.repo) {
512
- const userRepos = getUserRepos(config, msg.userId);
513
- const hasWildcard = userRepos.includes("*");
514
-
515
- if (!hasWildcard && userRepos.length === 1) {
516
- parsed.repo = userRepos[0];
517
- } else if (hasWildcard || userRepos.length > 1) {
518
- const repoNames = hasWildcard ? repoRegistry.getAllNames() : userRepos;
519
-
520
- if (repoNames.length === 1) {
521
- parsed.repo = repoNames[0];
522
- } else if (repoNames.length === 0) {
523
- const reply = "No repos discovered yet. Set one up with `init repo <name> <git-url>` or configure GitHub sync.";
524
- await msg.reply(reply);
525
- return;
526
- } else {
527
- // Multiple repos — run inline (like discuss) with repo list context
528
- // Claude answers from knowledge + gh CLI, no worktree needed
529
- const repoList = repoNames.join(", ");
530
- const history = sessions.getHistory(msg.userId, 6);
531
- const formatHint = PLATFORM_FORMAT_HINTS[msg.platform] || PLATFORM_FORMAT_HINTS.slack;
532
- const inlinePrompt = `${OVE_PERSONA}\n\nAvailable repos: ${repoList}\n\nThe user has access to ${repoNames.length} repos. Based on their message, determine which repo(s) they mean and answer their question fully. Use \`gh\` CLI to query GitHub (e.g. \`gh pr list --repo owner/repo\`, \`gh issue list --repo owner/repo\`). Do NOT stop after identifying the repo — complete the actual task.\n\n${formatHint}\n\n${parsed.rawText}`;
533
-
534
- await msg.updateStatus("Working...");
535
- try {
536
- const runner = getRunner(config.runner?.name);
537
- const result = await runner.run(inlinePrompt, config.reposDir, { maxTurns: 10 }, (event) => {
538
- // Only show tool usage as status — don't relay response text (causes duplicates)
539
- if (event.kind === "tool") msg.updateStatus(`Using ${event.tool}...`);
540
- });
541
- const parts = splitAndReply(result.output, msg.platform);
542
- for (const part of parts) await msg.reply(part);
543
- sessions.addMessage(msg.userId, "assistant", result.output.slice(0, 500));
544
- } catch (err) {
545
- await msg.reply(`Error: ${String(err).slice(0, 500)}`);
546
- }
547
- return;
548
- }
549
- } else {
550
- const reply = "You don't have access to any repos yet. Set one up:\n`init repo <name> <git-url> [branch]`\nExample: `init repo my-app git@github.com:user/my-app.git`";
551
- await msg.reply(reply);
552
- return;
553
- }
554
- }
555
-
556
- // Auth check
557
- if (!isAuthorized(config, msg.userId, parsed.repo)) {
558
- await msg.reply(`Not authorized for ${parsed.repo}.`);
559
- return;
560
- }
561
-
562
- // Check repo exists — config overrides or registry
563
- const repoInfo = getRepoInfo(parsed.repo);
564
- if (!repoInfo) {
565
- await msg.reply(`Unknown repo: ${parsed.repo}`);
566
- return;
567
- }
568
-
569
- // Build prompt with conversation context
570
- const history = sessions.getHistory(msg.userId, 6);
571
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
572
-
573
- // Enqueue the task
574
- const taskId = queue.enqueue({
575
- userId: msg.userId,
576
- repo: parsed.repo,
577
- prompt,
578
- });
579
-
580
- // Store reply callback for later
581
- pendingReplies.set(taskId, msg);
582
-
583
- // Only ack if there's a queue backlog for this repo
584
- const stats = queue.stats();
585
- if (stats.running > 0 || stats.pending > 1) {
586
- await msg.reply(`Queued — ${stats.pending} task${stats.pending > 1 ? "s" : ""} ahead.`);
587
- }
588
- logger.info("task enqueued", { taskId, repo: parsed.repo, type: parsed.type });
589
- }
590
-
591
- // Pending event responses — stores taskId → adapter for responding
592
- const pendingEventReplies = new Map<string, { adapter: EventAdapter; event: IncomingEvent }>();
593
-
594
- async function handleEvent(event: IncomingEvent, adapter: EventAdapter) {
595
- const parsed = parseMessage(event.text);
596
-
597
- if (!parsed.repo) {
598
- const userRepos = getUserRepos(config, event.userId);
599
- if (userRepos.length === 1) {
600
- parsed.repo = userRepos[0];
601
- } else if ("repo" in event.source && event.source.repo) {
602
- const shortName = event.source.repo.split("/").pop() || event.source.repo;
603
- if (isAuthorized(config, event.userId, shortName)) {
604
- parsed.repo = shortName;
605
- }
606
- }
607
- }
608
-
609
- if (!parsed.repo) {
610
- await adapter.respondToEvent(event.eventId, "Couldn't determine which repo. Configure your user in config.json.");
611
- return;
612
- }
613
-
614
- if (!isAuthorized(config, event.userId, parsed.repo)) {
615
- await adapter.respondToEvent(event.eventId, `Not authorized for ${parsed.repo}.`);
616
- return;
617
- }
618
-
619
- const repoInfo = getRepoInfo(parsed.repo);
620
- if (!repoInfo) {
621
- await adapter.respondToEvent(event.eventId, `Unknown repo: ${parsed.repo}.`);
622
- return;
623
- }
624
-
625
- const prompt = buildContextualPrompt(parsed, [], OVE_PERSONA);
626
- const taskId = queue.enqueue({
627
- userId: event.userId,
628
- repo: parsed.repo,
629
- prompt,
630
- });
631
-
632
- pendingEventReplies.set(taskId, { adapter, event });
633
- logger.info("event task enqueued", { taskId, eventId: event.eventId, repo: parsed.repo });
634
- }
635
-
636
- async function processTask(task: import("./queue").Task) {
637
- const isCreateProject = task.taskType === "create-project";
638
- const repoInfo = isCreateProject ? null : getRepoInfo(task.repo);
639
-
640
- if (!isCreateProject && !repoInfo) {
641
- queue.fail(task.id, `Unknown repo: ${task.repo}`);
642
- return;
643
- }
644
-
645
- const abortController = new AbortController();
646
- runningProcesses.set(task.id, { abort: abortController, task });
647
-
648
- const originalMsg = pendingReplies.get(task.id);
649
- const statusLog: string[] = [];
650
-
651
- try {
652
- // Status update
653
- await originalMsg?.updateStatus(`Working on it...`);
654
-
655
- let workDir: string;
656
-
657
- if (isCreateProject) {
658
- // Create project directory under reposDir
659
- workDir = join(config.reposDir, task.repo);
660
- await Bun.write(join(workDir, ".gitkeep"), "");
661
- } else {
662
- // Ensure repo is cloned and up to date
663
- await repos.cloneIfNeeded(task.repo, repoInfo!.url);
664
- await repos.pull(task.repo, repoInfo!.defaultBranch);
665
-
666
- // Create worktree
667
- workDir = await repos.createWorktree(
668
- task.repo,
669
- task.id,
670
- repoInfo!.defaultBranch
671
- );
672
- }
673
-
674
- try {
675
- // Write MCP config to temp file if configured
676
- let mcpConfigPath: string | undefined;
677
- if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
678
- mcpConfigPath = join(tmpdir(), `mcp-${task.id}.json`);
679
- await Bun.write(mcpConfigPath, JSON.stringify({ mcpServers: config.mcpServers }));
680
- }
681
-
682
- const taskRunner = getRunnerForRepo(task.repo);
683
- const runOpts = getRunnerOptsForRepo(task.repo, {
684
- maxTurns: config.claude.maxTurns,
685
- mcpConfigPath,
686
- signal: abortController.signal,
687
- });
688
-
689
- const result = await taskRunner.run(
690
- task.prompt,
691
- workDir,
692
- runOpts,
693
- (event: StatusEvent) => {
694
- // Only surface meaningful status — skip noisy tool-by-tool updates
695
- if (event.kind === "tool") {
696
- // Summarize tool usage without raw details
697
- const last = statusLog[statusLog.length - 1];
698
- const summary = `Using ${event.tool}...`;
699
- if (last !== summary) statusLog.push(summary);
700
- } else {
701
- statusLog.push(event.text.slice(0, 200));
702
- }
703
- // Show only last 5 lines to reduce noise
704
- originalMsg?.updateStatus(statusLog.slice(-5).join("\n"));
705
- }
706
- );
707
-
708
- // Clean up MCP temp file
709
- if (mcpConfigPath) {
710
- try {
711
- await unlink(mcpConfigPath);
712
- } catch {}
713
- }
714
-
715
- if (result.success) {
716
- queue.complete(task.id, result.output);
717
- logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
718
-
719
- // Reply to user — split long results across messages
720
- const platform = originalMsg?.platform || "slack";
721
- const parts = splitAndReply(result.output, platform);
722
- for (const part of parts) {
723
- await originalMsg?.reply(part);
724
- }
725
- sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
726
-
727
- // Check if this was triggered by an event adapter
728
- const eventReply = pendingEventReplies.get(task.id);
729
- if (eventReply) {
730
- await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
731
- pendingEventReplies.delete(task.id);
732
- }
733
- } else {
734
- queue.fail(task.id, result.output);
735
- logger.error("task failed", { taskId: task.id });
736
- await originalMsg?.reply(`Task failed: ${result.output.slice(0, 500)}`);
737
- sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
738
-
739
- // Notify event adapter of failure too
740
- const eventReply = pendingEventReplies.get(task.id);
741
- if (eventReply) {
742
- await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
743
- pendingEventReplies.delete(task.id);
744
- }
745
- }
746
- } finally {
747
- // Only clean up worktree for non-create-project tasks
748
- if (!isCreateProject) {
749
- await repos.removeWorktree(task.repo, task.id).catch(() => {});
750
- }
751
- }
752
- } catch (err) {
753
- queue.fail(task.id, String(err));
754
- logger.error("task processing error", { taskId: task.id, error: String(err) });
755
- await originalMsg?.reply(`Task error: ${String(err).slice(0, 500)}`);
756
- } finally {
757
- runningProcesses.delete(task.id);
758
- pendingReplies.delete(task.id);
759
- }
760
- }
761
-
762
- // Worker loop — runs up to maxConcurrent tasks in parallel, per-repo serialization via dequeue()
763
- async function workerLoop() {
764
- const maxConcurrent = 5;
765
-
766
- while (true) {
767
- if (runningProcesses.size < maxConcurrent) {
768
- try {
769
- const task = queue.dequeue();
770
- if (task) {
771
- processTask(task).catch((err) =>
772
- logger.error("worker task error", { taskId: task.id, error: String(err) })
773
- );
774
- continue; // try to grab another immediately
775
- }
776
- } catch (err) {
777
- logger.error("worker loop error", { error: String(err) });
778
- }
779
- }
780
- await Bun.sleep(2000);
781
- }
782
- }
783
-
784
161
  // Main
785
162
  async function main() {
786
- // Reset tasks stuck as "running" from a previous interrupted session
163
+ // Capture stale tasks before resetting so we can notify users
164
+ const staleTasks = queue.listActive().filter((t) => t.status === "running");
787
165
  const staleCount = queue.resetStale();
788
166
  if (staleCount > 0) {
789
167
  logger.info("reset stale tasks", { count: staleCount });
790
168
  }
791
169
 
792
- logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
170
+ logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude", tracing: trace.isEnabled() });
793
171
 
794
- // Start GitHub repo sync (non-blocking)
795
172
  startGitHubSync().catch((err) =>
796
173
  logger.warn("initial github sync failed", { error: String(err) })
797
174
  );
798
175
 
176
+ const handleMessage = createMessageHandler({
177
+ config,
178
+ queue,
179
+ sessions,
180
+ schedules,
181
+ repoRegistry,
182
+ trace,
183
+ pendingReplies,
184
+ pendingEventReplies,
185
+ runningProcesses,
186
+ getRunner,
187
+ getRunnerForRepo,
188
+ getRepoInfo,
189
+ });
190
+
191
+ const handleEvent = createEventHandler({
192
+ config,
193
+ queue,
194
+ sessions,
195
+ schedules,
196
+ repoRegistry,
197
+ trace,
198
+ pendingReplies,
199
+ pendingEventReplies,
200
+ runningProcesses,
201
+ getRunner,
202
+ getRunnerForRepo,
203
+ getRepoInfo,
204
+ });
205
+
799
206
  for (const adapter of adapters) {
800
207
  await adapter.start(handleMessage);
801
208
  }
802
209
 
803
- // Start event adapters
804
210
  for (const ea of eventAdapters) {
211
+ // Wire up chat handler for HTTP adapter so web UI gets full chat features
212
+ if (ea instanceof HttpApiAdapter) {
213
+ ea.setMessageHandler(handleMessage);
214
+ ea.setAdapters(adapters, eventAdapters);
215
+ }
805
216
  await ea.start((event) => handleEvent(event, ea));
806
217
  }
807
218
 
808
- // Start cron loop — checks both config-based and user-created schedules
809
219
  const configCron = config.cron || [];
810
220
  startCronLoop(
811
221
  () => [
@@ -822,17 +232,43 @@ async function main() {
822
232
  userId: cronTask.userId,
823
233
  repo: cronTask.repo,
824
234
  prompt: buildCronPrompt(cronTask.prompt),
235
+ taskType: "cron",
825
236
  });
826
237
  }
827
238
  );
828
239
  logger.info("cron started", { configTasks: configCron.length });
829
240
 
830
- // Start worker loop
831
- workerLoop();
241
+ const worker = createWorker({
242
+ config,
243
+ queue,
244
+ repos,
245
+ sessions,
246
+ adapters,
247
+ pendingReplies,
248
+ pendingEventReplies,
249
+ runningProcesses,
250
+ getRunnerForRepo,
251
+ getRunnerOptsForRepo,
252
+ getRepoInfo,
253
+ trace,
254
+ });
255
+ worker.start();
256
+
257
+ // Notify users whose tasks were interrupted by restart
258
+ if (staleTasks.length > 0) {
259
+ for (const task of staleTasks) {
260
+ const platform = task.userId.split(":")[0];
261
+ const adapter = adapters.find((a) => a.constructor.name.toLowerCase().includes(platform));
262
+ if (adapter?.sendToUser) {
263
+ adapter.sendToUser(task.userId, `Your task was interrupted by a restart: "${task.prompt.slice(0, 100)}". Please re-submit if needed.`).catch((err) =>
264
+ logger.warn("failed to notify user of interrupted task", { userId: task.userId, error: String(err) })
265
+ );
266
+ }
267
+ }
268
+ }
832
269
 
833
270
  logger.info("ove ready");
834
271
 
835
- // Graceful shutdown
836
272
  async function shutdown() {
837
273
  logger.info("shutting down...");
838
274
  for (const adapter of adapters) {