@lovenyberg/ove 0.3.0 → 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/router.ts CHANGED
@@ -15,7 +15,8 @@ export type MessageType =
15
15
  | "list-schedules"
16
16
  | "remove-schedule"
17
17
  | "list-tasks"
18
- | "cancel-task";
18
+ | "cancel-task"
19
+ | "trace";
19
20
 
20
21
  export interface ParsedMessage {
21
22
  type: MessageType;
@@ -45,6 +46,9 @@ export function parseMessage(text: string): ParsedMessage {
45
46
  const cancelMatch = trimmed.match(/^(?:\/)?cancel\s+(\S+)$/i);
46
47
  if (cancelMatch) return { type: "cancel-task", args: { taskId: cancelMatch[1] }, rawText: trimmed };
47
48
 
49
+ const traceMatch = trimmed.match(/^(?:\/)?trace(?:\s+(\S+))?$/i);
50
+ if (traceMatch) return { type: "trace", args: { taskId: traceMatch[1] }, rawText: trimmed };
51
+
48
52
  // Natural language status inquiries — short messages asking about progress
49
53
  if (isStatusInquiry(lower)) return { type: "status", args: {}, rawText: trimmed };
50
54
 
@@ -142,6 +146,8 @@ export function buildCronPrompt(prompt: string): string {
142
146
  return `This is an autonomous scheduled task. Do not ask questions — make your own decisions and proceed with the work. If there are multiple options, pick the best one and go.\n\n${prompt}`;
143
147
  }
144
148
 
149
+ const CHAT_PIPELINE_HINT = "You are running in a chat pipeline — your text output is sent to the user via a messaging app. Do NOT use AskUserQuestion or other interactive CLI tools (they don't work here). If you need to ask the user something, include the question and numbered options directly in your text response — the user will reply in chat and you'll see their answer in the next message.";
150
+
145
151
  export function buildContextualPrompt(
146
152
  parsed: ParsedMessage,
147
153
  history: { role: string; content: string }[],
@@ -152,7 +158,7 @@ export function buildContextualPrompt(
152
158
  history.slice(0, -1).map((m) => `${m.role}: ${m.content}`).join("\n") +
153
159
  "\n\nCurrent request:\n"
154
160
  : "";
155
- return persona + "\n\n" + contextPrefix + buildPrompt(parsed);
161
+ return persona + "\n\n" + CHAT_PIPELINE_HINT + "\n\n" + contextPrefix + buildPrompt(parsed);
156
162
  }
157
163
 
158
164
  export function buildPrompt(parsed: ParsedMessage): string {
@@ -3,6 +3,20 @@ import { logger } from "../logger";
3
3
  import { which } from "bun";
4
4
  import { realpathSync } from "node:fs";
5
5
 
6
+ const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
7
+
8
+ function withTimeout(proc: { exited: Promise<number>; kill(): void }): Promise<number | "timeout"> {
9
+ return Promise.race([
10
+ proc.exited,
11
+ new Promise<"timeout">((resolve) =>
12
+ setTimeout(() => {
13
+ proc.kill();
14
+ resolve("timeout");
15
+ }, TIMEOUT_MS)
16
+ ),
17
+ ]);
18
+ }
19
+
6
20
  export function summarizeToolInput(name: string, input: any): string {
7
21
  if (!input) return "";
8
22
  switch (name) {
@@ -36,7 +50,7 @@ export class ClaudeRunner implements AgentRunner {
36
50
 
37
51
  buildArgs(prompt: string, opts: RunOptions): string[] {
38
52
  // stream-json requires --verbose in claude CLI
39
- const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions"];
53
+ const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions", "--disallowed-tools", "AskUserQuestion"];
40
54
  if (opts.mcpConfigPath) args.push("--mcp-config", opts.mcpConfigPath);
41
55
  return args;
42
56
  }
@@ -58,7 +72,7 @@ export class ClaudeRunner implements AgentRunner {
58
72
  }
59
73
 
60
74
  let resultText: string | null = null;
61
- let lastTextBlock: string | null = null;
75
+ const textBlocks: string[] = [];
62
76
  const decoder = new TextDecoder();
63
77
  const reader = proc.stdout.getReader();
64
78
  try {
@@ -76,7 +90,7 @@ export class ClaudeRunner implements AgentRunner {
76
90
  if (msg.type === "assistant" && msg.message?.content) {
77
91
  for (const block of msg.message.content) {
78
92
  if (block.type === "text") {
79
- lastTextBlock = block.text;
93
+ textBlocks.push(block.text);
80
94
  if (onStatus) onStatus({ kind: "text", text: block.text });
81
95
  }
82
96
  if (block.type === "tool_use" && onStatus) {
@@ -97,16 +111,21 @@ export class ClaudeRunner implements AgentRunner {
97
111
  reader.releaseLock();
98
112
  }
99
113
 
100
- const exitCode = await proc.exited;
114
+ const exitCode = await withTimeout(proc);
101
115
  const durationMs = Date.now() - startTime;
102
116
 
117
+ if (exitCode === "timeout") {
118
+ logger.error("claude task timed out", { durationMs });
119
+ return { success: false, output: `Claude task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs };
120
+ }
121
+
103
122
  if (exitCode !== 0) {
104
123
  const stderr = await new Response(proc.stderr).text();
105
124
  logger.error("claude task failed", { exitCode, stderr, durationMs });
106
125
  return { success: false, output: stderr || "Claude task failed", durationMs };
107
126
  }
108
127
 
109
- const finalOutput = resultText || lastTextBlock || "Task completed (no output)";
128
+ const finalOutput = resultText || textBlocks.join("\n\n") || "Task completed (no output)";
110
129
  logger.info("claude task completed", { durationMs });
111
130
  return { success: true, output: finalOutput, durationMs };
112
131
  }
@@ -8,6 +8,20 @@ import { logger } from "../logger";
8
8
  import { which } from "bun";
9
9
  import { realpathSync } from "node:fs";
10
10
 
11
+ const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
12
+
13
+ function withTimeout(proc: { exited: Promise<number>; kill(): void }): Promise<number | "timeout"> {
14
+ return Promise.race([
15
+ proc.exited,
16
+ new Promise<"timeout">((resolve) =>
17
+ setTimeout(() => {
18
+ proc.kill();
19
+ resolve("timeout");
20
+ }, TIMEOUT_MS)
21
+ ),
22
+ ]);
23
+ }
24
+
11
25
  export function summarizeCodexItem(
12
26
  item: any
13
27
  ): { tool: string; input: string } | null {
@@ -125,9 +139,14 @@ export class CodexRunner implements AgentRunner {
125
139
  reader.releaseLock();
126
140
  }
127
141
 
128
- const exitCode = await proc.exited;
142
+ const exitCode = await withTimeout(proc);
129
143
  const durationMs = Date.now() - startTime;
130
144
 
145
+ if (exitCode === "timeout") {
146
+ logger.error("codex task timed out", { durationMs });
147
+ return { success: false, output: `Codex task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs };
148
+ }
149
+
131
150
  if (exitCode !== 0) {
132
151
  const stderr = await new Response(proc.stderr).text();
133
152
  const output = errorMessage || stderr || "Codex task failed";
package/src/trace.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ export interface TraceEvent {
4
+ id: number;
5
+ taskId: string;
6
+ ts: string;
7
+ kind: "status" | "tool" | "lifecycle" | "output" | "error";
8
+ summary: string;
9
+ detail: string | null;
10
+ }
11
+
12
+ interface TraceRow {
13
+ id: number;
14
+ task_id: string;
15
+ ts: string;
16
+ kind: string;
17
+ summary: string;
18
+ detail: string | null;
19
+ }
20
+
21
+ export class TraceStore {
22
+ private db: Database;
23
+ private enabled: boolean;
24
+
25
+ constructor(db: Database) {
26
+ this.db = db;
27
+ this.enabled = process.env.OVE_TRACE === "true";
28
+ this.db.run(`
29
+ CREATE TABLE IF NOT EXISTS task_traces (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ task_id TEXT NOT NULL,
32
+ ts TEXT NOT NULL,
33
+ kind TEXT NOT NULL,
34
+ summary TEXT NOT NULL,
35
+ detail TEXT
36
+ )
37
+ `);
38
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_trace_task_id ON task_traces(task_id)`);
39
+ }
40
+
41
+ isEnabled(): boolean {
42
+ return this.enabled;
43
+ }
44
+
45
+ append(taskId: string, kind: TraceEvent["kind"], summary: string, detail?: string) {
46
+ if (!this.enabled) return;
47
+ this.db.run(
48
+ `INSERT INTO task_traces (task_id, ts, kind, summary, detail) VALUES (?, ?, ?, ?, ?)`,
49
+ [taskId, new Date().toISOString(), kind, summary, detail ?? null]
50
+ );
51
+ }
52
+
53
+ getByTask(taskId: string, limit: number = 100): TraceEvent[] {
54
+ const rows = this.db
55
+ .query(`SELECT * FROM task_traces WHERE task_id = ? ORDER BY id ASC LIMIT ?`)
56
+ .all(taskId, limit) as TraceRow[];
57
+ return rows.map((r) => ({
58
+ id: r.id,
59
+ taskId: r.task_id,
60
+ ts: r.ts,
61
+ kind: r.kind as TraceEvent["kind"],
62
+ summary: r.summary,
63
+ detail: r.detail,
64
+ }));
65
+ }
66
+
67
+ cleanup(olderThanDays: number = 7) {
68
+ const cutoff = new Date(Date.now() - olderThanDays * 86_400_000).toISOString();
69
+ this.db.run(`DELETE FROM task_traces WHERE ts < ?`, [cutoff]);
70
+ }
71
+ }
package/src/worker.ts CHANGED
@@ -7,20 +7,53 @@ import type { Config } from "./config";
7
7
  import type { TaskQueue, Task } from "./queue";
8
8
  import type { RepoManager } from "./repos";
9
9
  import type { SessionStore } from "./sessions";
10
- import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/types";
10
+ import type { IncomingMessage, ChatAdapter, EventAdapter, IncomingEvent } from "./adapters/types";
11
11
  import type { AgentRunner, RunOptions, StatusEvent } from "./runner";
12
+ import type { TraceStore } from "./trace";
13
+ import type { DebouncedFunction } from "./adapters/debounce";
12
14
 
13
15
  export interface WorkerDeps {
14
16
  config: Config;
15
17
  queue: TaskQueue;
16
18
  repos: RepoManager;
17
19
  sessions: SessionStore;
20
+ adapters: ChatAdapter[];
18
21
  pendingReplies: Map<string, IncomingMessage>;
19
22
  pendingEventReplies: Map<string, { adapter: EventAdapter; event: IncomingEvent }>;
20
23
  runningProcesses: Map<string, { abort: AbortController; task: Task }>;
21
24
  getRunnerForRepo: (repo: string) => AgentRunner;
22
25
  getRunnerOptsForRepo: (repo: string, baseOpts: RunOptions) => RunOptions;
23
26
  getRepoInfo: (repoName: string) => { url: string; defaultBranch: string } | null;
27
+ trace: TraceStore;
28
+ }
29
+
30
+ function findAdapterForUser(userId: string, adapters: ChatAdapter[]): ChatAdapter | undefined {
31
+ const platform = userId.split(":")[0]; // e.g. "telegram", "slack", "discord"
32
+ return adapters.find((a) => a.constructor.name.toLowerCase().includes(platform));
33
+ }
34
+
35
+ async function replyWithFallback(
36
+ text: string,
37
+ originalMsg: IncomingMessage | undefined,
38
+ userId: string,
39
+ adapters: ChatAdapter[],
40
+ ) {
41
+ if (originalMsg) {
42
+ try {
43
+ await originalMsg.reply(text);
44
+ return;
45
+ } catch (err) {
46
+ logger.warn("original reply failed, trying fallback", { userId, error: String(err) });
47
+ }
48
+ }
49
+ const adapter = findAdapterForUser(userId, adapters);
50
+ if (adapter?.sendToUser) {
51
+ try {
52
+ await adapter.sendToUser(userId, text);
53
+ } catch (err) {
54
+ logger.warn("fallback sendToUser failed", { userId, error: String(err) });
55
+ }
56
+ }
24
57
  }
25
58
 
26
59
  async function processTask(task: Task, deps: WorkerDeps) {
@@ -37,6 +70,10 @@ async function processTask(task: Task, deps: WorkerDeps) {
37
70
 
38
71
  const originalMsg = deps.pendingReplies.get(task.id);
39
72
  const statusLog: string[] = [];
73
+ const trace = deps.trace;
74
+ const startTime = Date.now();
75
+
76
+ trace.append(task.id, "lifecycle", "Task started", task.prompt);
40
77
 
41
78
  try {
42
79
  await originalMsg?.updateStatus(`Working on it...`);
@@ -65,8 +102,11 @@ async function processTask(task: Task, deps: WorkerDeps) {
65
102
  }
66
103
 
67
104
  const taskRunner = deps.getRunnerForRepo(task.repo);
105
+ // Cron tasks are autonomous — give them plenty of room to finish
106
+ const baseTurns = deps.config.claude.maxTurns;
107
+ const maxTurns = task.taskType === "cron" ? Math.max(baseTurns, 100) : baseTurns;
68
108
  const runOpts = deps.getRunnerOptsForRepo(task.repo, {
69
- maxTurns: deps.config.claude.maxTurns,
109
+ maxTurns,
70
110
  mcpConfigPath,
71
111
  signal: abortController.signal,
72
112
  });
@@ -80,8 +120,10 @@ async function processTask(task: Task, deps: WorkerDeps) {
80
120
  const last = statusLog[statusLog.length - 1];
81
121
  const summary = `Using ${event.tool}...`;
82
122
  if (last !== summary) statusLog.push(summary);
123
+ trace.append(task.id, "tool", summary, event.input.slice(0, 2000));
83
124
  } else {
84
125
  statusLog.push(event.text.slice(0, 200));
126
+ trace.append(task.id, "status", event.text.slice(0, 200));
85
127
  }
86
128
  originalMsg?.updateStatus(statusLog.slice(-5).join("\n"));
87
129
  }
@@ -95,15 +137,26 @@ async function processTask(task: Task, deps: WorkerDeps) {
95
137
  }
96
138
  }
97
139
 
140
+ // Cancel any pending debounced status update before sending final reply
141
+ const updateFn = originalMsg?.updateStatus as DebouncedFunction<any> | undefined;
142
+ if (updateFn?.cancel) updateFn.cancel();
143
+
144
+ trace.append(task.id, "output", "Runner output", result.output.slice(0, 10_000));
145
+
98
146
  if (result.success) {
99
147
  deps.queue.complete(task.id, result.output);
100
148
  logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
101
149
 
102
150
  const platform = originalMsg?.platform || "slack";
103
- const parts = splitAndReply(result.output, platform);
151
+ const isCron = task.taskType === "cron";
152
+ const replyText = isCron
153
+ ? `[Scheduled: ${task.repo}]\n${result.output}`
154
+ : result.output;
155
+ const parts = splitAndReply(replyText, platform);
104
156
  for (const part of parts) {
105
- await originalMsg?.reply(part);
157
+ await replyWithFallback(part, originalMsg, task.userId, deps.adapters);
106
158
  }
159
+ trace.append(task.id, "lifecycle", "Reply sent");
107
160
  deps.sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
108
161
 
109
162
  const eventReply = deps.pendingEventReplies.get(task.id);
@@ -111,10 +164,13 @@ async function processTask(task: Task, deps: WorkerDeps) {
111
164
  await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
112
165
  deps.pendingEventReplies.delete(task.id);
113
166
  }
167
+
168
+ const elapsed = Date.now() - startTime;
169
+ trace.append(task.id, "lifecycle", `Task completed in ${elapsed}ms`);
114
170
  } else {
115
171
  deps.queue.fail(task.id, result.output);
116
172
  logger.error("task failed", { taskId: task.id });
117
- await originalMsg?.reply(`Task failed: ${result.output.slice(0, 500)}`);
173
+ await replyWithFallback(`Task failed: ${result.output.slice(0, 500)}`, originalMsg, task.userId, deps.adapters);
118
174
  deps.sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
119
175
 
120
176
  const eventReply = deps.pendingEventReplies.get(task.id);
@@ -122,6 +178,9 @@ async function processTask(task: Task, deps: WorkerDeps) {
122
178
  await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
123
179
  deps.pendingEventReplies.delete(task.id);
124
180
  }
181
+
182
+ const elapsed = Date.now() - startTime;
183
+ trace.append(task.id, "lifecycle", `Task failed in ${elapsed}ms`, result.output.slice(0, 2000));
125
184
  }
126
185
  } finally {
127
186
  if (!isCreateProject) {
@@ -131,7 +190,10 @@ async function processTask(task: Task, deps: WorkerDeps) {
131
190
  } catch (err) {
132
191
  deps.queue.fail(task.id, String(err));
133
192
  logger.error("task processing error", { taskId: task.id, error: String(err) });
134
- await originalMsg?.reply(`Task error: ${String(err).slice(0, 500)}`);
193
+ trace.append(task.id, "error", "Task processing error", String(err).slice(0, 2000));
194
+ const updateFn = originalMsg?.updateStatus as DebouncedFunction<any> | undefined;
195
+ if (updateFn?.cancel) updateFn.cancel();
196
+ await replyWithFallback(`Task error: ${String(err).slice(0, 500)}`, originalMsg, task.userId, deps.adapters);
135
197
  } finally {
136
198
  deps.runningProcesses.delete(task.id);
137
199
  deps.pendingReplies.delete(task.id);