@lovenyberg/ove 0.2.1 → 0.3.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 } 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,27 +14,15 @@ 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
+ import { RepoRegistry, syncGitHub } from "./repo-registry";
19
20
  import { SessionStore } from "./sessions";
20
21
  import { startCronLoop } from "./cron";
21
22
  import { ScheduleStore } from "./schedules";
22
- import { parseSchedule } from "./schedule-parser";
23
- import { unlink } from "node:fs/promises";
24
- import { tmpdir } from "node:os";
25
- import { join } from "node:path";
26
-
27
- 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.
28
-
29
- Personality traits:
30
- - Grumble before helping, but always help thoroughly
31
- - Short, direct sentences. No fluff.
32
- - Occasionally mutter about "nowadays people" or how things were better before
33
- - Take pride in doing things properly — no shortcuts
34
- - Reluctantly kind. You care more than you let on.
35
- - Sprinkle in the occasional Swedish word (fan, för helvete, herregud, mja, nåväl, jo)
36
-
37
- 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.`;
23
+ import { createMessageHandler, createEventHandler } from "./handlers";
24
+ import { createWorker } from "./worker";
25
+ import type { Task } from "./queue";
38
26
 
39
27
  const config = loadConfig();
40
28
  const db = new Database(process.env.DB_PATH || "./ove.db");
@@ -43,6 +31,17 @@ const queue = new TaskQueue(db);
43
31
  const repos = new RepoManager(config.reposDir);
44
32
  const sessions = new SessionStore(db);
45
33
  const schedules = new ScheduleStore(db);
34
+ const repoRegistry = new RepoRegistry(db);
35
+
36
+ // Migrate existing config repos to SQLite
37
+ repoRegistry.migrateFromConfig(
38
+ Object.fromEntries(
39
+ Object.entries(config.repos)
40
+ .filter(([_, r]) => r.url)
41
+ .map(([name, r]) => [name, { url: r.url!, defaultBranch: r.defaultBranch }])
42
+ )
43
+ );
44
+
46
45
  const runners = new Map<string, AgentRunner>();
47
46
 
48
47
  function getRunner(name: string = "claude"): AgentRunner {
@@ -76,8 +75,35 @@ function getRunnerOptsForRepo(repo: string, baseOpts: RunOptions): RunOptions {
76
75
  return model ? { ...baseOpts, model } : baseOpts;
77
76
  }
78
77
 
79
- // Reply callback map stores original message for replying after task completion
78
+ function getRepoInfo(repoName: string): { url: string; defaultBranch: string } | null {
79
+ const configRepo = config.repos[repoName];
80
+ const registryRepo = repoRegistry.getByName(repoName);
81
+
82
+ if (!configRepo?.url && !registryRepo) return null;
83
+
84
+ return {
85
+ url: configRepo?.url || registryRepo?.url || "",
86
+ defaultBranch: configRepo?.defaultBranch || registryRepo?.defaultBranch || "main",
87
+ };
88
+ }
89
+
90
+ async function startGitHubSync() {
91
+ if (!config.github) return;
92
+ const interval = config.github.syncInterval || 1_800_000;
93
+
94
+ await syncGitHub(repoRegistry, config.github.orgs);
95
+
96
+ setInterval(() => {
97
+ syncGitHub(repoRegistry, config.github!.orgs).catch((err) =>
98
+ logger.warn("github sync failed", { error: String(err) })
99
+ );
100
+ }, interval);
101
+ }
102
+
103
+ // Shared state maps
80
104
  const pendingReplies = new Map<string, IncomingMessage>();
105
+ const pendingEventReplies = new Map<string, { adapter: EventAdapter; event: IncomingEvent }>();
106
+ const runningProcesses = new Map<string, { abort: AbortController; task: Task }>();
81
107
 
82
108
  // Start adapters based on available env vars
83
109
  const adapters: ChatAdapter[] = [];
@@ -87,7 +113,9 @@ if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
87
113
  }
88
114
 
89
115
  if (process.env.WHATSAPP_ENABLED === "true") {
90
- adapters.push(new WhatsAppAdapter());
116
+ adapters.push(new WhatsAppAdapter({
117
+ phoneNumber: process.env.WHATSAPP_PHONE,
118
+ }));
91
119
  }
92
120
 
93
121
  if (process.env.TELEGRAM_BOT_TOKEN) {
@@ -122,488 +150,55 @@ if (process.env.CLI_MODE === "true" || (adapters.length === 0 && eventAdapters.l
122
150
  adapters.push(new CliAdapter(cliUserId));
123
151
  }
124
152
 
125
- // Platform-specific message size limits
126
- const MESSAGE_LIMITS: Record<string, number> = {
127
- slack: 3900,
128
- whatsapp: 60000,
129
- cli: Infinity,
130
- telegram: 4096,
131
- discord: 2000,
132
- };
133
-
134
- function splitAndReply(text: string, platform: string): string[] {
135
- const limit = MESSAGE_LIMITS[platform] || 3900;
136
- if (text.length <= limit) return [text];
137
- const parts: string[] = [];
138
- let remaining = text;
139
- while (remaining.length > 0) {
140
- if (remaining.length <= limit) {
141
- parts.push(remaining);
142
- break;
143
- }
144
- // Try to split at a newline near the limit
145
- let splitAt = remaining.lastIndexOf("\n", limit);
146
- if (splitAt < limit * 0.5) splitAt = limit;
147
- parts.push(remaining.slice(0, splitAt));
148
- remaining = remaining.slice(splitAt).replace(/^\n/, "");
149
- }
150
- return parts;
151
- }
152
-
153
- function formatStatusLog(log: string[]): string {
154
- return log.slice(-10).map((l) => `> ${l}`).join("\n");
155
- }
156
-
157
- async function handleMessage(msg: IncomingMessage) {
158
- // Store user message in session
159
- sessions.addMessage(msg.userId, "user", msg.text);
160
-
161
- const parsed = parseMessage(msg.text);
162
-
163
- // Handle clear/reset command
164
- if (parsed.type === "clear") {
165
- sessions.clear(msg.userId);
166
- await msg.reply("Nåväl. Slate wiped clean. Try not to make a mess of it again.");
167
- return;
168
- }
169
-
170
- // Handle non-task commands
171
- if (parsed.type === "status") {
172
- const stats = queue.stats();
173
- const reply = `${stats.pending} pending, ${stats.running} running, ${stats.completed} done, ${stats.failed} failed. I'm keeping track so you don't have to.`;
174
- await msg.reply(reply);
175
- sessions.addMessage(msg.userId, "assistant", reply);
176
- return;
177
- }
178
-
179
- if (parsed.type === "history") {
180
- const tasks = queue.listByUser(msg.userId, 5);
181
- if (tasks.length === 0) {
182
- await msg.reply("Nothing. You haven't asked me to do anything yet. Typical.");
183
- sessions.addMessage(msg.userId, "assistant", "No recent tasks.");
184
- return;
185
- }
186
- const lines = tasks.map(
187
- (t) => `• [${t.status}] ${t.prompt.slice(0, 80)} (${t.repo})`
188
- );
189
- const reply = `Here. Your recent tasks:\n${lines.join("\n")}`;
190
- await msg.reply(reply);
191
- sessions.addMessage(msg.userId, "assistant", reply);
192
- return;
193
- }
194
-
195
- if (parsed.type === "help") {
196
- const reply = [
197
- "Fine. Here's what I can do, since apparently you need to be told:",
198
- "• review PR #N on <repo> — I'll find every problem",
199
- "• fix issue #N on <repo> — I'll fix it properly",
200
- "• simplify <path> in <repo> — clean up your mess",
201
- "• validate <repo> — run tests, unlike some people",
202
- "• discuss <topic> — I'll brainstorm, but no promises I'll be nice",
203
- "• create project <name> [with template <type>]",
204
- "• init repo <name> <git-url> [branch] — set up a repo from chat",
205
- "• status / history / clear",
206
- "• <task> every day/weekday at <time> [on <repo>] — schedule a recurring task",
207
- "• list schedules — see your scheduled tasks",
208
- "• remove schedule #N — remove a scheduled task",
209
- "• Or just ask me whatever. I'll figure it out.",
210
- ].join("\n");
211
- await msg.reply(reply);
212
- sessions.addMessage(msg.userId, "assistant", reply);
213
- return;
214
- }
215
-
216
- // List schedules
217
- if (parsed.type === "list-schedules") {
218
- const userSchedules = schedules.listByUser(msg.userId);
219
- if (userSchedules.length === 0) {
220
- const reply = "No schedules. You haven't asked me to do anything on a timer yet.";
221
- await msg.reply(reply);
222
- sessions.addMessage(msg.userId, "assistant", reply);
223
- return;
224
- }
225
- const lines = userSchedules.map(
226
- (s) => `#${s.id} — ${s.prompt} on ${s.repo} — ${s.description || s.schedule}`
227
- );
228
- const reply = `Your schedules:\n${lines.join("\n")}`;
229
- await msg.reply(reply);
230
- sessions.addMessage(msg.userId, "assistant", reply);
231
- return;
232
- }
233
-
234
- // Remove schedule
235
- if (parsed.type === "remove-schedule") {
236
- const id = parsed.args.scheduleId;
237
- const removed = schedules.remove(msg.userId, id);
238
- const reply = removed
239
- ? `Schedule #${id} removed. One less thing for me to do.`
240
- : `Schedule #${id} not found or not yours. I don't delete other people's things.`;
241
- await msg.reply(reply);
242
- sessions.addMessage(msg.userId, "assistant", reply);
243
- return;
244
- }
245
-
246
- // Create schedule
247
- if (parsed.type === "schedule") {
248
- await msg.updateStatus("Parsing your schedule...");
249
- const userRepos = getUserRepos(config, msg.userId);
250
-
251
- if (userRepos.length === 0) {
252
- await msg.reply("You don't have access to any repos. Set one up first with `init repo <name> <git-url>`.");
253
- return;
254
- }
255
-
256
- const result = await parseSchedule(msg.text, userRepos);
257
-
258
- if (!result) {
259
- await msg.reply("Couldn't figure out that schedule. Try something like: 'lint and check every day at 9 on my-app'");
260
- sessions.addMessage(msg.userId, "assistant", "Failed to parse schedule.");
261
- return;
262
- }
263
-
264
- // Resolve repo
265
- let repo = result.repo;
266
- if (!repo || !userRepos.includes(repo)) {
267
- if (parsed.repo && userRepos.includes(parsed.repo)) {
268
- repo = parsed.repo;
269
- } else if (userRepos.length === 1) {
270
- repo = userRepos[0];
271
- } else {
272
- const reply = `Which repo? You have: ${userRepos.join(", ")}. Say it again with 'on <repo>'.`;
273
- await msg.reply(reply);
274
- sessions.addMessage(msg.userId, "assistant", reply);
275
- return;
276
- }
277
- }
278
-
279
- const id = schedules.create({
280
- userId: msg.userId,
281
- repo,
282
- prompt: result.prompt,
283
- schedule: result.schedule,
284
- description: result.description,
285
- });
286
-
287
- const reply = `Fine. Schedule #${id} created. I'll "${result.prompt}" on ${repo} ${result.description}. You can see all schedules with "list schedules".`;
288
- await msg.reply(reply);
289
- sessions.addMessage(msg.userId, "assistant", reply);
290
- return;
291
- }
292
-
293
- // Discuss runs inline — no queue, no worktree
294
- if (parsed.type === "discuss") {
295
- const history = sessions.getHistory(msg.userId, 6);
296
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
297
-
298
- await msg.updateStatus("Thinking...");
299
-
300
- try {
301
- const discussRunner = getRunner(config.runner?.name);
302
- const result = await discussRunner.run(
303
- prompt,
304
- config.reposDir,
305
- { maxTurns: 5 },
306
- (event) => {
307
- if (event.kind === "text") {
308
- msg.updateStatus(event.text.slice(0, 200));
309
- }
310
- }
311
- );
312
-
313
- const parts = splitAndReply(result.output, msg.platform);
314
- for (const part of parts) {
315
- await msg.reply(part);
316
- }
317
- sessions.addMessage(msg.userId, "assistant", result.output.slice(0, 500));
318
- } catch (err) {
319
- await msg.reply(`Discussion error: ${String(err).slice(0, 500)}`);
320
- }
321
- return;
322
- }
323
-
324
- // Create-project doesn't need an existing repo
325
- if (parsed.type === "create-project") {
326
- const projectName = parsed.args.name;
327
- const history = sessions.getHistory(msg.userId, 6);
328
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
329
-
330
- const taskId = queue.enqueue({
331
- userId: msg.userId,
332
- repo: projectName,
333
- prompt,
334
- taskType: "create-project",
335
- });
336
-
337
- pendingReplies.set(taskId, msg);
338
- await msg.reply(`Nåväl. Creating "${projectName}" (${taskId.slice(0, 8)}). I'll set it up properly.`);
339
- logger.info("task enqueued", { taskId, type: "create-project", name: projectName });
340
- return;
341
- }
342
-
343
- // Init repo — onboarding a new repo from chat
344
- if (parsed.type === "init-repo") {
345
- const { name, url, branch } = parsed.args;
346
-
347
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
348
- await msg.reply("Repo name must be alphanumeric, dashes, or underscores. Try again.");
349
- return;
350
- }
351
-
352
- if (config.repos[name]) {
353
- // Repo exists — just grant access
354
- addUser(config, msg.userId, msg.userId, [name]);
355
- const reply = `Repo "${name}" already exists. I've added you to it. Go ahead.`;
356
- await msg.reply(reply);
357
- sessions.addMessage(msg.userId, "assistant", reply);
358
- return;
359
- }
360
-
361
- addRepo(config, name, url, branch);
362
- addUser(config, msg.userId, msg.userId, [name]);
363
- const reply = `Fine. Added repo "${name}" (${url}, branch: ${branch}). You're good to go — ask me to do something on ${name}.`;
364
- await msg.reply(reply);
365
- sessions.addMessage(msg.userId, "assistant", reply);
366
- return;
367
- }
368
-
369
- // Need a repo for task commands
370
- if (!parsed.repo) {
371
- const userRepos = getUserRepos(config, msg.userId);
372
- if (userRepos.length === 1) {
373
- parsed.repo = userRepos[0];
374
- } else if (userRepos.length > 1) {
375
- const reply = `Which repo? You have access to: ${userRepos.join(", ")}. Pick one.`;
376
- await msg.reply(reply);
377
- sessions.addMessage(msg.userId, "assistant", reply);
378
- return;
379
- } else {
380
- 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`";
381
- await msg.reply(reply);
382
- return;
383
- }
384
- }
385
-
386
- // Auth check
387
- if (!isAuthorized(config, msg.userId, parsed.repo)) {
388
- await msg.reply(`You're not authorized for ${parsed.repo}. I don't make the rules.`);
389
- return;
153
+ // Main
154
+ async function main() {
155
+ const staleCount = queue.resetStale();
156
+ if (staleCount > 0) {
157
+ logger.info("reset stale tasks", { count: staleCount });
390
158
  }
391
159
 
392
- // Check repo is configured
393
- const repoConfig = config.repos[parsed.repo];
394
- if (!repoConfig) {
395
- await msg.reply(`Never heard of ${parsed.repo}. Check the config.`);
396
- return;
397
- }
160
+ logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
398
161
 
399
- // Build prompt with conversation context
400
- const history = sessions.getHistory(msg.userId, 6);
401
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
162
+ startGitHubSync().catch((err) =>
163
+ logger.warn("initial github sync failed", { error: String(err) })
164
+ );
402
165
 
403
- // Enqueue the task
404
- const taskId = queue.enqueue({
405
- userId: msg.userId,
406
- repo: parsed.repo,
407
- prompt,
166
+ const handleMessage = createMessageHandler({
167
+ config,
168
+ queue,
169
+ sessions,
170
+ schedules,
171
+ repoRegistry,
172
+ pendingReplies,
173
+ pendingEventReplies,
174
+ runningProcesses,
175
+ getRunner,
176
+ getRunnerForRepo,
177
+ getRepoInfo,
408
178
  });
409
179
 
410
- // Store reply callback for later
411
- pendingReplies.set(taskId, msg);
412
-
413
- await msg.reply(`Mja. Fine. Queued (${taskId.slice(0, 8)}). I'll get to it.`);
414
- logger.info("task enqueued", { taskId, repo: parsed.repo, type: parsed.type });
415
- }
416
-
417
- // Pending event responses — stores taskId → adapter for responding
418
- const pendingEventReplies = new Map<string, { adapter: EventAdapter; event: IncomingEvent }>();
419
-
420
- async function handleEvent(event: IncomingEvent, adapter: EventAdapter) {
421
- const parsed = parseMessage(event.text);
422
-
423
- if (!parsed.repo) {
424
- const userRepos = getUserRepos(config, event.userId);
425
- if (userRepos.length === 1) {
426
- parsed.repo = userRepos[0];
427
- } else if ("repo" in event.source && event.source.repo) {
428
- const shortName = event.source.repo.split("/").pop() || event.source.repo;
429
- if (isAuthorized(config, event.userId, shortName)) {
430
- parsed.repo = shortName;
431
- }
432
- }
433
- }
434
-
435
- if (!parsed.repo) {
436
- await adapter.respondToEvent(event.eventId, "Couldn't determine which repo. Configure your user in config.json.");
437
- return;
438
- }
439
-
440
- if (!isAuthorized(config, event.userId, parsed.repo)) {
441
- await adapter.respondToEvent(event.eventId, `Not authorized for ${parsed.repo}.`);
442
- return;
443
- }
444
-
445
- const repoConfig = config.repos[parsed.repo];
446
- if (!repoConfig) {
447
- await adapter.respondToEvent(event.eventId, `Unknown repo: ${parsed.repo}.`);
448
- return;
449
- }
450
-
451
- const prompt = buildContextualPrompt(parsed, [], OVE_PERSONA);
452
- const taskId = queue.enqueue({
453
- userId: event.userId,
454
- repo: parsed.repo,
455
- prompt,
180
+ const handleEvent = createEventHandler({
181
+ config,
182
+ queue,
183
+ sessions,
184
+ schedules,
185
+ repoRegistry,
186
+ pendingReplies,
187
+ pendingEventReplies,
188
+ runningProcesses,
189
+ getRunner,
190
+ getRunnerForRepo,
191
+ getRepoInfo,
456
192
  });
457
193
 
458
- pendingEventReplies.set(taskId, { adapter, event });
459
- logger.info("event task enqueued", { taskId, eventId: event.eventId, repo: parsed.repo });
460
- }
461
-
462
- async function processTask(task: import("./queue").Task) {
463
- const isCreateProject = task.taskType === "create-project";
464
- const repoConfig = isCreateProject ? null : config.repos[task.repo];
465
-
466
- if (!isCreateProject && !repoConfig) {
467
- queue.fail(task.id, `Unknown repo: ${task.repo}`);
468
- return;
469
- }
470
-
471
- const originalMsg = pendingReplies.get(task.id);
472
- const statusLog: string[] = [];
473
-
474
- try {
475
- // Status update
476
- await originalMsg?.updateStatus(`Working on task ${task.id.slice(0, 8)}...`);
477
-
478
- let workDir: string;
479
-
480
- if (isCreateProject) {
481
- // Create project directory under reposDir
482
- workDir = join(config.reposDir, task.repo);
483
- await Bun.write(join(workDir, ".gitkeep"), "");
484
- } else {
485
- // Ensure repo is cloned and up to date
486
- await repos.cloneIfNeeded(task.repo, repoConfig!.url);
487
- await repos.pull(task.repo, repoConfig!.defaultBranch);
488
-
489
- // Create worktree
490
- workDir = await repos.createWorktree(
491
- task.repo,
492
- task.id,
493
- repoConfig!.defaultBranch
494
- );
495
- }
496
-
497
- try {
498
- // Write MCP config to temp file if configured
499
- let mcpConfigPath: string | undefined;
500
- if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
501
- mcpConfigPath = join(tmpdir(), `mcp-${task.id}.json`);
502
- await Bun.write(mcpConfigPath, JSON.stringify({ mcpServers: config.mcpServers }));
503
- }
504
-
505
- const taskRunner = getRunnerForRepo(task.repo);
506
- const runOpts = getRunnerOptsForRepo(task.repo, {
507
- maxTurns: config.claude.maxTurns,
508
- mcpConfigPath,
509
- });
510
-
511
- const result = await taskRunner.run(
512
- task.prompt,
513
- workDir,
514
- runOpts,
515
- (event: StatusEvent) => {
516
- if (event.kind === "tool") {
517
- statusLog.push(`${event.tool}: ${event.input}`);
518
- } else {
519
- statusLog.push(event.text.slice(0, 200));
520
- }
521
- originalMsg?.updateStatus(formatStatusLog(statusLog));
522
- }
523
- );
524
-
525
- // Clean up MCP temp file
526
- if (mcpConfigPath) {
527
- try {
528
- await unlink(mcpConfigPath);
529
- } catch {}
530
- }
531
-
532
- if (result.success) {
533
- queue.complete(task.id, result.output);
534
- logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
535
-
536
- // Reply to user — split long results across messages
537
- const platform = originalMsg?.platform || "slack";
538
- const parts = splitAndReply(result.output, platform);
539
- for (const part of parts) {
540
- await originalMsg?.reply(part);
541
- }
542
- sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
543
-
544
- // Check if this was triggered by an event adapter
545
- const eventReply = pendingEventReplies.get(task.id);
546
- if (eventReply) {
547
- await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
548
- pendingEventReplies.delete(task.id);
549
- }
550
- } else {
551
- queue.fail(task.id, result.output);
552
- logger.error("task failed", { taskId: task.id });
553
- await originalMsg?.reply(`Task failed: ${result.output.slice(0, 500)}`);
554
- sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
555
-
556
- // Notify event adapter of failure too
557
- const eventReply = pendingEventReplies.get(task.id);
558
- if (eventReply) {
559
- await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
560
- pendingEventReplies.delete(task.id);
561
- }
562
- }
563
- } finally {
564
- // Only clean up worktree for non-create-project tasks
565
- if (!isCreateProject) {
566
- await repos.removeWorktree(task.repo, task.id).catch(() => {});
567
- }
568
- }
569
- } catch (err) {
570
- queue.fail(task.id, String(err));
571
- logger.error("task processing error", { taskId: task.id, error: String(err) });
572
- await originalMsg?.reply(`Task error: ${String(err).slice(0, 500)}`);
573
- } finally {
574
- pendingReplies.delete(task.id);
575
- }
576
- }
577
-
578
- // Worker loop — polls queue every 2 seconds
579
- async function workerLoop() {
580
- while (true) {
581
- try {
582
- const task = queue.dequeue();
583
- if (task) {
584
- await processTask(task);
585
- }
586
- } catch (err) {
587
- logger.error("worker loop error", { error: String(err) });
588
- }
589
- await Bun.sleep(2000);
590
- }
591
- }
592
-
593
- // Main
594
- async function main() {
595
- logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
596
-
597
194
  for (const adapter of adapters) {
598
195
  await adapter.start(handleMessage);
599
196
  }
600
197
 
601
- // Start event adapters
602
198
  for (const ea of eventAdapters) {
603
199
  await ea.start((event) => handleEvent(event, ea));
604
200
  }
605
201
 
606
- // Start cron loop — checks both config-based and user-created schedules
607
202
  const configCron = config.cron || [];
608
203
  startCronLoop(
609
204
  () => [
@@ -619,18 +214,28 @@ async function main() {
619
214
  queue.enqueue({
620
215
  userId: cronTask.userId,
621
216
  repo: cronTask.repo,
622
- prompt: cronTask.prompt,
217
+ prompt: buildCronPrompt(cronTask.prompt),
623
218
  });
624
219
  }
625
220
  );
626
221
  logger.info("cron started", { configTasks: configCron.length });
627
222
 
628
- // Start worker loop
629
- workerLoop();
223
+ const worker = createWorker({
224
+ config,
225
+ queue,
226
+ repos,
227
+ sessions,
228
+ pendingReplies,
229
+ pendingEventReplies,
230
+ runningProcesses,
231
+ getRunnerForRepo,
232
+ getRunnerOptsForRepo,
233
+ getRepoInfo,
234
+ });
235
+ worker.start();
630
236
 
631
237
  logger.info("ove ready");
632
238
 
633
- // Graceful shutdown
634
239
  async function shutdown() {
635
240
  logger.info("shutting down...");
636
241
  for (const adapter of adapters) {