@ruvnet/open-claude-code 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +27 -0
  2. package/src/agents/loader.mjs +89 -0
  3. package/src/agents/parser.mjs +98 -0
  4. package/src/agents/teams.mjs +123 -0
  5. package/src/auth/oauth.mjs +220 -0
  6. package/src/config/cli-args.mjs +139 -0
  7. package/src/config/env.mjs +261 -0
  8. package/src/config/settings.mjs +119 -0
  9. package/src/core/agent-loop.mjs +462 -0
  10. package/src/core/cache.mjs +105 -0
  11. package/src/core/checkpoints.mjs +141 -0
  12. package/src/core/context-manager.mjs +198 -0
  13. package/src/core/providers.mjs +219 -0
  14. package/src/core/rate-limiter.mjs +119 -0
  15. package/src/core/scheduler.mjs +173 -0
  16. package/src/core/session.mjs +143 -0
  17. package/src/core/streaming.mjs +182 -0
  18. package/src/core/system-prompt.mjs +135 -0
  19. package/src/hooks/engine.mjs +162 -0
  20. package/src/index.mjs +220 -0
  21. package/src/mcp/client.mjs +253 -0
  22. package/src/mcp/transport-shttp.mjs +130 -0
  23. package/src/mcp/transport-sse.mjs +131 -0
  24. package/src/mcp/transport-ws.mjs +134 -0
  25. package/src/permissions/checker.mjs +57 -0
  26. package/src/permissions/injection-check.mjs +60 -0
  27. package/src/permissions/path-check.mjs +102 -0
  28. package/src/permissions/prompt.mjs +73 -0
  29. package/src/permissions/sandbox.mjs +112 -0
  30. package/src/plugins/loader.mjs +138 -0
  31. package/src/skills/loader.mjs +147 -0
  32. package/src/skills/runner.mjs +55 -0
  33. package/src/telemetry/index.mjs +96 -0
  34. package/src/tools/agent.mjs +127 -0
  35. package/src/tools/ask-user.mjs +61 -0
  36. package/src/tools/bash.mjs +148 -0
  37. package/src/tools/cron-create.mjs +120 -0
  38. package/src/tools/cron-delete.mjs +49 -0
  39. package/src/tools/cron-list.mjs +37 -0
  40. package/src/tools/edit.mjs +82 -0
  41. package/src/tools/enter-worktree.mjs +69 -0
  42. package/src/tools/exit-worktree.mjs +57 -0
  43. package/src/tools/glob.mjs +117 -0
  44. package/src/tools/grep.mjs +127 -0
  45. package/src/tools/ls.mjs +58 -0
  46. package/src/tools/lsp.mjs +115 -0
  47. package/src/tools/multi-edit.mjs +94 -0
  48. package/src/tools/notebook-edit.mjs +96 -0
  49. package/src/tools/read-mcp-resource.mjs +57 -0
  50. package/src/tools/read.mjs +138 -0
  51. package/src/tools/registry.mjs +114 -0
  52. package/src/tools/remote-trigger.mjs +84 -0
  53. package/src/tools/send-message.mjs +64 -0
  54. package/src/tools/skill.mjs +52 -0
  55. package/src/tools/todo-write.mjs +68 -0
  56. package/src/tools/tool-search.mjs +77 -0
  57. package/src/tools/web-fetch.mjs +65 -0
  58. package/src/tools/web-search.mjs +89 -0
  59. package/src/tools/write.mjs +55 -0
  60. package/src/ui/commands.mjs +499 -0
  61. package/src/ui/ink-app.mjs +231 -0
  62. package/src/ui/repl.mjs +144 -0
  63. package/test/test.mjs +1857 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Context Manager — tracks token usage and compacts conversation history.
3
+ *
4
+ * Features:
5
+ * - Proper token estimation (4 chars ~ 1 token for English)
6
+ * - Micro-compaction (remove stale tool results older than 5 turns)
7
+ * - Keep system prompt and recent 3 turns intact during compaction
8
+ * - Track pre/post compaction token counts
9
+ */
10
+
11
+ const DEFAULT_MAX_TOKENS = 180000; // ~200k model limit with buffer
12
+ const COMPACT_THRESHOLD = 0.80;
13
+ const CHARS_PER_TOKEN = 4; // rough estimate for English text
14
+ const STALE_TOOL_RESULT_TURNS = 5; // tool results older than this are micro-compacted
15
+
16
+ export class ContextManager {
17
+ /**
18
+ * @param {number} maxTokens - Maximum tokens for context window
19
+ */
20
+ constructor(maxTokens = DEFAULT_MAX_TOKENS) {
21
+ this.maxTokens = maxTokens;
22
+ this.threshold = COMPACT_THRESHOLD;
23
+ this.compactionCount = 0;
24
+ this.lastPreCompactTokens = 0;
25
+ this.lastPostCompactTokens = 0;
26
+ }
27
+
28
+ /**
29
+ * Estimate token count for a message array.
30
+ * Uses character-based heuristic (no external tokenizer dependency).
31
+ * @param {Array} messages - conversation messages
32
+ * @returns {number} estimated token count
33
+ */
34
+ getTokenCount(messages) {
35
+ let chars = 0;
36
+ for (const msg of messages) {
37
+ // Role overhead (~4 tokens)
38
+ chars += 16;
39
+
40
+ if (typeof msg.content === 'string') {
41
+ chars += msg.content.length;
42
+ } else if (Array.isArray(msg.content)) {
43
+ for (const block of msg.content) {
44
+ if (block.type === 'text') chars += (block.text || '').length;
45
+ else if (block.type === 'tool_result') chars += (block.content || '').length;
46
+ else if (block.type === 'tool_use') chars += JSON.stringify(block.input || {}).length + 20;
47
+ else if (block.type === 'thinking') chars += (block.thinking || '').length;
48
+ else chars += JSON.stringify(block).length;
49
+ }
50
+ }
51
+ }
52
+ return Math.ceil(chars / CHARS_PER_TOKEN);
53
+ }
54
+
55
+ /**
56
+ * Check if compaction is needed.
57
+ * @param {Array} messages - current conversation messages
58
+ * @returns {boolean}
59
+ */
60
+ shouldCompact(messages) {
61
+ const tokenCount = this.getTokenCount(messages);
62
+ return tokenCount >= this.maxTokens * this.threshold;
63
+ }
64
+
65
+ /**
66
+ * Micro-compact: remove verbose tool results from messages older than N turns.
67
+ * Keeps the tool call reference but truncates result content.
68
+ * @param {Array} messages
69
+ * @param {number} recentTurns - number of recent user/assistant pairs to preserve
70
+ * @returns {Array}
71
+ */
72
+ microCompact(messages, recentTurns = STALE_TOOL_RESULT_TURNS) {
73
+ // Count turns (each user message is roughly one turn)
74
+ let turnCount = 0;
75
+ for (let i = messages.length - 1; i >= 0; i--) {
76
+ if (messages[i].role === 'user') turnCount++;
77
+ }
78
+
79
+ if (turnCount <= recentTurns) return messages;
80
+
81
+ // Mark the boundary: keep last recentTurns user messages intact
82
+ let usersSeen = 0;
83
+ let boundary = messages.length;
84
+ for (let i = messages.length - 1; i >= 0; i--) {
85
+ if (messages[i].role === 'user') {
86
+ usersSeen++;
87
+ if (usersSeen >= recentTurns) {
88
+ boundary = i;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+
94
+ // Truncate tool results before the boundary
95
+ const result = messages.map((msg, idx) => {
96
+ if (idx >= boundary) return msg;
97
+ if (!Array.isArray(msg.content)) return msg;
98
+
99
+ const newContent = msg.content.map(block => {
100
+ if (block.type === 'tool_result' && typeof block.content === 'string' && block.content.length > 200) {
101
+ return {
102
+ ...block,
103
+ content: block.content.slice(0, 100) + '...[truncated]',
104
+ };
105
+ }
106
+ return block;
107
+ });
108
+
109
+ return { ...msg, content: newContent };
110
+ });
111
+
112
+ return result;
113
+ }
114
+
115
+ /**
116
+ * Compact messages by summarizing older history.
117
+ * Keeps the most recent N messages intact and replaces older ones
118
+ * with a summary message.
119
+ *
120
+ * @param {Array} messages - current conversation messages
121
+ * @param {number} keepRecent - number of recent messages to preserve (default 6 = ~3 turns)
122
+ * @returns {Array} compacted message array
123
+ */
124
+ compact(messages, keepRecent = 6) {
125
+ if (messages.length <= keepRecent) return messages;
126
+
127
+ this.lastPreCompactTokens = this.getTokenCount(messages);
128
+ this.compactionCount++;
129
+
130
+ // First try micro-compaction
131
+ let working = this.microCompact(messages);
132
+ if (!this.shouldCompact(working)) {
133
+ this.lastPostCompactTokens = this.getTokenCount(working);
134
+ return working;
135
+ }
136
+
137
+ // Full compaction
138
+ const oldMessages = messages.slice(0, -keepRecent);
139
+ const recentMessages = messages.slice(-keepRecent);
140
+
141
+ // Build a summary of old messages
142
+ const summaryParts = [];
143
+ for (const msg of oldMessages) {
144
+ const role = msg.role;
145
+ let text = '';
146
+ if (typeof msg.content === 'string') {
147
+ text = msg.content.slice(0, 200);
148
+ } else if (Array.isArray(msg.content)) {
149
+ text = msg.content
150
+ .map(b => {
151
+ if (b.type === 'text') return b.text?.slice(0, 100);
152
+ if (b.type === 'tool_use') return `[tool:${b.name}]`;
153
+ if (b.type === 'tool_result') return `[result:${String(b.content).slice(0, 80)}]`;
154
+ return `[${b.type}]`;
155
+ })
156
+ .filter(Boolean)
157
+ .join(' ');
158
+ }
159
+ if (text) summaryParts.push(`${role}: ${text}`);
160
+ }
161
+
162
+ const summary = {
163
+ role: 'user',
164
+ content: `[Context compacted — summary of ${oldMessages.length} earlier messages]\n` +
165
+ summaryParts.join('\n').slice(0, 2000),
166
+ };
167
+
168
+ const compacted = [summary, ...recentMessages];
169
+ this.lastPostCompactTokens = this.getTokenCount(compacted);
170
+ return compacted;
171
+ }
172
+
173
+ /**
174
+ * Add a message and auto-compact if needed.
175
+ * @param {Array} messages - mutable message array
176
+ * @param {object} msg - new message to add
177
+ * @returns {Array} possibly compacted array with new message
178
+ */
179
+ addMessage(messages, msg) {
180
+ messages.push(msg);
181
+ if (this.shouldCompact(messages)) {
182
+ return this.compact(messages);
183
+ }
184
+ return messages;
185
+ }
186
+
187
+ /**
188
+ * Get compaction statistics.
189
+ * @returns {object}
190
+ */
191
+ getStats() {
192
+ return {
193
+ compactionCount: this.compactionCount,
194
+ lastPreCompactTokens: this.lastPreCompactTokens,
195
+ lastPostCompactTokens: this.lastPostCompactTokens,
196
+ };
197
+ }
198
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Multi-Provider — unified provider config and request/response transforms.
3
+ *
4
+ * Supports: Anthropic, OpenAI, Google, Bedrock (stub), Vertex (stub).
5
+ * Each provider defines endpoint, auth headers, and optional transforms.
6
+ */
7
+
8
+ const PROVIDERS = {
9
+ anthropic: {
10
+ name: 'Anthropic',
11
+ endpoint: 'https://api.anthropic.com/v1/messages',
12
+ envKey: 'ANTHROPIC_API_KEY',
13
+ authHeader(key) {
14
+ return {
15
+ 'x-api-key': key,
16
+ 'anthropic-version': '2023-06-01',
17
+ 'Content-Type': 'application/json',
18
+ };
19
+ },
20
+ models: ['claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-opus-4-6'],
21
+ },
22
+
23
+ openai: {
24
+ name: 'OpenAI',
25
+ endpoint: 'https://api.openai.com/v1/chat/completions',
26
+ envKey: 'OPENAI_API_KEY',
27
+ authHeader(key) {
28
+ return {
29
+ 'Authorization': `Bearer ${key}`,
30
+ 'Content-Type': 'application/json',
31
+ };
32
+ },
33
+ models: ['gpt-4o', 'gpt-4o-mini', 'o1-preview', 'o1-mini', 'o3-mini'],
34
+ transformRequest(body) {
35
+ const messages = [];
36
+ if (body.system) {
37
+ messages.push({ role: 'system', content: body.system });
38
+ }
39
+ for (const msg of body.messages || []) {
40
+ if (typeof msg.content === 'string') {
41
+ messages.push({ role: msg.role, content: msg.content });
42
+ } else if (Array.isArray(msg.content)) {
43
+ for (const block of msg.content) {
44
+ if (block.type === 'tool_result') {
45
+ messages.push({
46
+ role: 'tool',
47
+ tool_call_id: block.tool_use_id,
48
+ content: block.content,
49
+ });
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ const tools = (body.tools || []).map(t => ({
56
+ type: 'function',
57
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
58
+ }));
59
+
60
+ return {
61
+ model: body.model,
62
+ messages,
63
+ ...(tools.length > 0 && { tools }),
64
+ ...(body.max_tokens && { max_tokens: body.max_tokens }),
65
+ ...(body.stream && { stream: true }),
66
+ };
67
+ },
68
+ transformResponse(data) {
69
+ const choice = data.choices?.[0];
70
+ if (!choice) throw new Error('No choices in OpenAI response');
71
+
72
+ const content = [];
73
+ if (choice.message?.content) {
74
+ content.push({ type: 'text', text: choice.message.content });
75
+ }
76
+ if (choice.message?.tool_calls) {
77
+ for (const tc of choice.message.tool_calls) {
78
+ content.push({
79
+ type: 'tool_use',
80
+ id: tc.id,
81
+ name: tc.function.name,
82
+ input: JSON.parse(tc.function.arguments || '{}'),
83
+ });
84
+ }
85
+ }
86
+
87
+ return {
88
+ content,
89
+ stop_reason: choice.finish_reason === 'stop' ? 'end_turn' : choice.finish_reason,
90
+ usage: {
91
+ input_tokens: data.usage?.prompt_tokens || 0,
92
+ output_tokens: data.usage?.completion_tokens || 0,
93
+ },
94
+ };
95
+ },
96
+ },
97
+
98
+ google: {
99
+ name: 'Google',
100
+ endpoint: 'https://generativelanguage.googleapis.com/v1beta/models',
101
+ envKey: 'GOOGLE_API_KEY',
102
+ altEnvKey: 'GEMINI_API_KEY',
103
+ authHeader(key) {
104
+ return { 'Content-Type': 'application/json' };
105
+ },
106
+ models: ['gemini-2.0-flash', 'gemini-2.0-pro', 'gemini-1.5-flash'],
107
+ transformRequest(body) {
108
+ const contents = [];
109
+ for (const msg of body.messages || []) {
110
+ const role = msg.role === 'assistant' ? 'model' : 'user';
111
+ if (typeof msg.content === 'string') {
112
+ contents.push({ role, parts: [{ text: msg.content }] });
113
+ }
114
+ }
115
+
116
+ return {
117
+ contents,
118
+ ...(body.system && {
119
+ systemInstruction: { parts: [{ text: body.system }] },
120
+ }),
121
+ };
122
+ },
123
+ transformResponse(data) {
124
+ const candidate = data.candidates?.[0];
125
+ if (!candidate) throw new Error('No candidates in Google response');
126
+
127
+ const content = [];
128
+ for (const part of candidate.content?.parts || []) {
129
+ if (part.text) content.push({ type: 'text', text: part.text });
130
+ }
131
+
132
+ return {
133
+ content,
134
+ stop_reason: 'end_turn',
135
+ usage: {
136
+ input_tokens: data.usageMetadata?.promptTokenCount || 0,
137
+ output_tokens: data.usageMetadata?.candidatesTokenCount || 0,
138
+ },
139
+ };
140
+ },
141
+ },
142
+
143
+ bedrock: {
144
+ name: 'AWS Bedrock',
145
+ endpoint: null, // Dynamic based on region
146
+ envKey: 'AWS_ACCESS_KEY_ID',
147
+ models: ['anthropic.claude-3-sonnet', 'anthropic.claude-3-haiku'],
148
+ authHeader() {
149
+ // AWS SigV4 signing would go here
150
+ return { 'Content-Type': 'application/json' };
151
+ },
152
+ getEndpoint(model, region = 'us-east-1') {
153
+ return `https://bedrock-runtime.${region}.amazonaws.com/model/${model}/invoke`;
154
+ },
155
+ },
156
+
157
+ vertex: {
158
+ name: 'Google Vertex AI',
159
+ endpoint: null, // Dynamic based on project/region
160
+ envKey: 'GOOGLE_APPLICATION_CREDENTIALS',
161
+ models: ['claude-sonnet-4-6@anthropic'],
162
+ authHeader() {
163
+ // GCP bearer token would go here
164
+ return { 'Content-Type': 'application/json' };
165
+ },
166
+ getEndpoint(model, project, region = 'us-central1') {
167
+ return `https://${region}-aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/anthropic/models/${model}:rawPredict`;
168
+ },
169
+ },
170
+ };
171
+
172
+ /**
173
+ * Get the provider configuration for a given model.
174
+ * @param {string} model - model name
175
+ * @returns {object} provider config
176
+ */
177
+ export function getProvider(model) {
178
+ if (model.startsWith('claude') || model.startsWith('anthropic')) return PROVIDERS.anthropic;
179
+ if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) return PROVIDERS.openai;
180
+ if (model.startsWith('gemini')) return PROVIDERS.google;
181
+ return PROVIDERS.anthropic; // default
182
+ }
183
+
184
+ /**
185
+ * Get a provider by name.
186
+ * @param {string} name
187
+ * @returns {object|undefined}
188
+ */
189
+ export function getProviderByName(name) {
190
+ return PROVIDERS[name];
191
+ }
192
+
193
+ /**
194
+ * List all supported providers.
195
+ * @returns {Array<{ name: string, envKey: string, models: string[] }>}
196
+ */
197
+ export function listProviders() {
198
+ return Object.entries(PROVIDERS).map(([key, p]) => ({
199
+ id: key,
200
+ name: p.name,
201
+ envKey: p.envKey,
202
+ models: p.models || [],
203
+ hasEndpoint: !!p.endpoint,
204
+ }));
205
+ }
206
+
207
+ /**
208
+ * Check which providers have API keys configured.
209
+ * @returns {Array<{ id: string, name: string, configured: boolean }>}
210
+ */
211
+ export function checkProviderKeys() {
212
+ return Object.entries(PROVIDERS).map(([key, p]) => ({
213
+ id: key,
214
+ name: p.name,
215
+ configured: !!(process.env[p.envKey] || (p.altEnvKey && process.env[p.altEnvKey])),
216
+ }));
217
+ }
218
+
219
+ export { PROVIDERS };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Rate Limiter — handle 429 and 529 API responses.
3
+ *
4
+ * Implements exponential backoff with jitter for rate-limited
5
+ * and overloaded API responses. Tracks retry state per-instance.
6
+ */
7
+
8
+ export class RateLimiter {
9
+ /**
10
+ * @param {object} [options]
11
+ * @param {number} [options.maxRetries] - max number of retries (default: 5)
12
+ * @param {number} [options.baseDelay] - base delay in ms (default: 1000)
13
+ * @param {number} [options.maxDelay] - max delay in ms (default: 60000)
14
+ */
15
+ constructor(options = {}) {
16
+ this.maxRetries = options.maxRetries ?? 5;
17
+ this.baseDelay = options.baseDelay ?? 1000;
18
+ this.maxDelay = options.maxDelay ?? 60000;
19
+ this.retryAfter = 0;
20
+ this.retryCount = 0;
21
+ this.lastRetryAt = null;
22
+ }
23
+
24
+ /**
25
+ * Handle an API response and determine whether to retry.
26
+ * @param {{ status: number, headers: { get: (name: string) => string|null } }} response
27
+ * @returns {Promise<'ok'|'retry'|'fail'>}
28
+ */
29
+ async handleResponse(response) {
30
+ if (response.status === 429) {
31
+ // Rate limited
32
+ if (this.retryCount >= this.maxRetries) return 'fail';
33
+
34
+ const retryAfter = parseInt(response.headers?.get?.('retry-after') || '10', 10);
35
+ const delayMs = Math.min(retryAfter * 1000, this.maxDelay);
36
+ this.retryAfter = Date.now() + delayMs;
37
+ this.retryCount++;
38
+ this.lastRetryAt = new Date().toISOString();
39
+
40
+ await this.wait(delayMs);
41
+ return 'retry';
42
+ }
43
+
44
+ if (response.status === 529) {
45
+ // API overloaded
46
+ if (this.retryCount >= this.maxRetries) return 'fail';
47
+
48
+ const delay = this.calculateBackoff();
49
+ this.retryAfter = Date.now() + delay;
50
+ this.retryCount++;
51
+ this.lastRetryAt = new Date().toISOString();
52
+
53
+ await this.wait(delay);
54
+ return 'retry';
55
+ }
56
+
57
+ // Success — reset retry count
58
+ this.retryCount = 0;
59
+ return 'ok';
60
+ }
61
+
62
+ /**
63
+ * Calculate exponential backoff with jitter.
64
+ * @returns {number} delay in milliseconds
65
+ */
66
+ calculateBackoff() {
67
+ const exponential = this.baseDelay * Math.pow(2, this.retryCount);
68
+ const jitter = Math.random() * this.baseDelay;
69
+ return Math.min(exponential + jitter, this.maxDelay);
70
+ }
71
+
72
+ /**
73
+ * Check if we should wait before making a request.
74
+ * @returns {boolean}
75
+ */
76
+ shouldWait() {
77
+ return Date.now() < this.retryAfter;
78
+ }
79
+
80
+ /**
81
+ * Get remaining wait time in ms.
82
+ * @returns {number}
83
+ */
84
+ remainingWait() {
85
+ return Math.max(0, this.retryAfter - Date.now());
86
+ }
87
+
88
+ /**
89
+ * Reset all retry state.
90
+ */
91
+ reset() {
92
+ this.retryAfter = 0;
93
+ this.retryCount = 0;
94
+ this.lastRetryAt = null;
95
+ }
96
+
97
+ /**
98
+ * Get current limiter status.
99
+ */
100
+ status() {
101
+ return {
102
+ retryCount: this.retryCount,
103
+ maxRetries: this.maxRetries,
104
+ retryAfter: this.retryAfter,
105
+ lastRetryAt: this.lastRetryAt,
106
+ isWaiting: this.shouldWait(),
107
+ remainingMs: this.remainingWait(),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Wait for the specified duration.
113
+ * @param {number} ms
114
+ * @returns {Promise<void>}
115
+ */
116
+ wait(ms) {
117
+ return new Promise(resolve => setTimeout(resolve, ms));
118
+ }
119
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Scheduler — cron-based task scheduling.
3
+ *
4
+ * Stores scheduled tasks in ~/.claude/scheduled_tasks.json.
5
+ * Tasks have a cron expression, prompt, and optional environment.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import crypto from 'crypto';
12
+
13
+ export class Scheduler {
14
+ /**
15
+ * @param {string} [tasksFile] - path to scheduled tasks JSON
16
+ */
17
+ constructor(tasksFile) {
18
+ this.tasksFile = tasksFile ||
19
+ path.join(os.homedir(), '.claude', 'scheduled_tasks.json');
20
+ this.timers = new Map();
21
+ }
22
+
23
+ /**
24
+ * Create a new scheduled task.
25
+ * @param {string} cron - cron expression or interval shorthand (e.g., "5m", "1h")
26
+ * @param {string} prompt - prompt to execute
27
+ * @param {object} [options]
28
+ * @param {string} [options.name] - human-readable name
29
+ * @param {string} [options.model] - model to use
30
+ * @param {boolean} [options.enabled] - whether task is enabled (default: true)
31
+ * @returns {object} created task
32
+ */
33
+ async create(cron, prompt, options = {}) {
34
+ const tasks = this._loadTasks();
35
+ const task = {
36
+ id: `task_${crypto.randomBytes(4).toString('hex')}`,
37
+ name: options.name || `Task ${tasks.length + 1}`,
38
+ cron,
39
+ prompt,
40
+ model: options.model || null,
41
+ enabled: options.enabled !== false,
42
+ createdAt: new Date().toISOString(),
43
+ lastRun: null,
44
+ runCount: 0,
45
+ intervalMs: parseCronInterval(cron),
46
+ };
47
+
48
+ tasks.push(task);
49
+ this._saveTasks(tasks);
50
+ return task;
51
+ }
52
+
53
+ /**
54
+ * Delete a scheduled task by ID.
55
+ * @param {string} taskId
56
+ * @returns {boolean} true if deleted
57
+ */
58
+ async delete(taskId) {
59
+ const tasks = this._loadTasks();
60
+ const idx = tasks.findIndex(t => t.id === taskId);
61
+ if (idx === -1) return false;
62
+
63
+ tasks.splice(idx, 1);
64
+ this._saveTasks(tasks);
65
+
66
+ // Clear timer if running
67
+ if (this.timers.has(taskId)) {
68
+ clearInterval(this.timers.get(taskId));
69
+ this.timers.delete(taskId);
70
+ }
71
+
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * List all scheduled tasks.
77
+ * @returns {Array<object>}
78
+ */
79
+ async list() {
80
+ return this._loadTasks();
81
+ }
82
+
83
+ /**
84
+ * Check and return tasks that are due to run.
85
+ * @returns {Array<object>} tasks that are due
86
+ */
87
+ async runDue() {
88
+ const tasks = this._loadTasks();
89
+ const due = [];
90
+ const now = Date.now();
91
+
92
+ for (const task of tasks) {
93
+ if (!task.enabled) continue;
94
+ if (!task.intervalMs) continue;
95
+
96
+ const lastRun = task.lastRun ? new Date(task.lastRun).getTime() : 0;
97
+ if (now - lastRun >= task.intervalMs) {
98
+ task.lastRun = new Date().toISOString();
99
+ task.runCount = (task.runCount || 0) + 1;
100
+ due.push(task);
101
+ }
102
+ }
103
+
104
+ if (due.length > 0) {
105
+ this._saveTasks(tasks);
106
+ }
107
+
108
+ return due;
109
+ }
110
+
111
+ /**
112
+ * Enable or disable a task.
113
+ * @param {string} taskId
114
+ * @param {boolean} enabled
115
+ * @returns {boolean}
116
+ */
117
+ async setEnabled(taskId, enabled) {
118
+ const tasks = this._loadTasks();
119
+ const task = tasks.find(t => t.id === taskId);
120
+ if (!task) return false;
121
+
122
+ task.enabled = enabled;
123
+ this._saveTasks(tasks);
124
+ return true;
125
+ }
126
+
127
+ /**
128
+ * Load tasks from disk.
129
+ * @returns {Array<object>}
130
+ */
131
+ _loadTasks() {
132
+ try {
133
+ const raw = fs.readFileSync(this.tasksFile, 'utf-8');
134
+ return JSON.parse(raw);
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Save tasks to disk.
142
+ * @param {Array<object>} tasks
143
+ */
144
+ _saveTasks(tasks) {
145
+ try {
146
+ const dir = path.dirname(this.tasksFile);
147
+ fs.mkdirSync(dir, { recursive: true });
148
+ fs.writeFileSync(this.tasksFile, JSON.stringify(tasks, null, 2));
149
+ } catch {
150
+ // Best effort
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Parse a cron expression or interval shorthand into milliseconds.
157
+ * @param {string} cron
158
+ * @returns {number}
159
+ */
160
+ export function parseCronInterval(cron) {
161
+ const match = cron.match(/^(\d+)(s|m|h|d)$/);
162
+ if (match) {
163
+ const value = parseInt(match[1], 10);
164
+ const units = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
165
+ return value * (units[match[2]] || 60000);
166
+ }
167
+
168
+ const num = parseInt(cron, 10);
169
+ if (!isNaN(num)) return num * 60000;
170
+
171
+ // Full cron expression — default to 5 minutes
172
+ return 300000;
173
+ }