loopwork 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +528 -0
  3. package/bin/loopwork +0 -0
  4. package/examples/README.md +70 -0
  5. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
  6. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
  7. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
  8. package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
  9. package/examples/basic-json-backend/README.md +32 -0
  10. package/examples/basic-json-backend/TESTING.md +184 -0
  11. package/examples/basic-json-backend/hello.test.ts +9 -0
  12. package/examples/basic-json-backend/hello.ts +3 -0
  13. package/examples/basic-json-backend/loopwork.config.js +35 -0
  14. package/examples/basic-json-backend/math.test.ts +29 -0
  15. package/examples/basic-json-backend/math.ts +3 -0
  16. package/examples/basic-json-backend/package.json +15 -0
  17. package/examples/basic-json-backend/quick-start.sh +80 -0
  18. package/loopwork.config.ts +164 -0
  19. package/package.json +26 -0
  20. package/src/backends/github.ts +426 -0
  21. package/src/backends/index.ts +86 -0
  22. package/src/backends/json.ts +598 -0
  23. package/src/backends/plugin.ts +317 -0
  24. package/src/backends/types.ts +19 -0
  25. package/src/commands/init.ts +100 -0
  26. package/src/commands/run.ts +365 -0
  27. package/src/contracts/backend.ts +127 -0
  28. package/src/contracts/config.ts +129 -0
  29. package/src/contracts/index.ts +43 -0
  30. package/src/contracts/plugin.ts +82 -0
  31. package/src/contracts/task.ts +78 -0
  32. package/src/core/cli.ts +275 -0
  33. package/src/core/config.ts +165 -0
  34. package/src/core/state.ts +154 -0
  35. package/src/core/utils.ts +125 -0
  36. package/src/dashboard/cli.ts +449 -0
  37. package/src/dashboard/index.ts +6 -0
  38. package/src/dashboard/kanban.tsx +226 -0
  39. package/src/dashboard/tui.tsx +372 -0
  40. package/src/index.ts +19 -0
  41. package/src/mcp/server.ts +451 -0
  42. package/src/monitor/index.ts +420 -0
  43. package/src/plugins/asana.ts +192 -0
  44. package/src/plugins/cost-tracking.ts +402 -0
  45. package/src/plugins/discord.ts +269 -0
  46. package/src/plugins/everhour.ts +335 -0
  47. package/src/plugins/index.ts +253 -0
  48. package/src/plugins/telegram/bot.ts +517 -0
  49. package/src/plugins/telegram/index.ts +6 -0
  50. package/src/plugins/telegram/notifications.ts +198 -0
  51. package/src/plugins/todoist.ts +261 -0
  52. package/test/backends.test.ts +929 -0
  53. package/test/cli.test.ts +145 -0
  54. package/test/config.test.ts +90 -0
  55. package/test/e2e.test.ts +458 -0
  56. package/test/github-tasks.test.ts +191 -0
  57. package/test/loopwork-config-types.test.ts +288 -0
  58. package/test/monitor.test.ts +123 -0
  59. package/test/plugins.test.ts +1175 -0
  60. package/test/state.test.ts +295 -0
  61. package/test/utils.test.ts +60 -0
  62. package/tsconfig.json +20 -0
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Cost Tracking for Loopwork
3
+ *
4
+ * Tracks token usage and costs for AI CLI tools (Claude, OpenCode, Gemini)
5
+ */
6
+
7
+ import fs from 'fs'
8
+ import path from 'path'
9
+ import type { LoopworkPlugin, PluginTask, LoopStats } from '../contracts'
10
+
11
+ // ============================================================================
12
+ // Token Pricing (per 1M tokens, in USD)
13
+ // ============================================================================
14
+
15
+ export interface ModelPricing {
16
+ inputPer1M: number
17
+ outputPer1M: number
18
+ cacheReadPer1M?: number
19
+ cacheWritePer1M?: number
20
+ }
21
+
22
+ export const MODEL_PRICING: Record<string, ModelPricing> = {
23
+ // Claude models
24
+ 'claude-3-opus': { inputPer1M: 15.00, outputPer1M: 75.00 },
25
+ 'claude-3-sonnet': { inputPer1M: 3.00, outputPer1M: 15.00 },
26
+ 'claude-3-haiku': { inputPer1M: 0.25, outputPer1M: 1.25 },
27
+ 'claude-3.5-sonnet': { inputPer1M: 3.00, outputPer1M: 15.00 },
28
+ 'claude-3.5-haiku': { inputPer1M: 0.80, outputPer1M: 4.00 },
29
+ 'claude-opus-4': { inputPer1M: 15.00, outputPer1M: 75.00 },
30
+ 'claude-sonnet-4': { inputPer1M: 3.00, outputPer1M: 15.00 },
31
+
32
+ // OpenAI models (for opencode)
33
+ 'gpt-4': { inputPer1M: 30.00, outputPer1M: 60.00 },
34
+ 'gpt-4-turbo': { inputPer1M: 10.00, outputPer1M: 30.00 },
35
+ 'gpt-4o': { inputPer1M: 2.50, outputPer1M: 10.00 },
36
+ 'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.60 },
37
+ 'o1': { inputPer1M: 15.00, outputPer1M: 60.00 },
38
+ 'o1-mini': { inputPer1M: 3.00, outputPer1M: 12.00 },
39
+
40
+ // Google models (for gemini)
41
+ 'gemini-1.5-pro': { inputPer1M: 1.25, outputPer1M: 5.00 },
42
+ 'gemini-1.5-flash': { inputPer1M: 0.075, outputPer1M: 0.30 },
43
+ 'gemini-2.0-flash': { inputPer1M: 0.10, outputPer1M: 0.40 },
44
+
45
+ // Default fallback
46
+ 'default': { inputPer1M: 3.00, outputPer1M: 15.00 },
47
+ }
48
+
49
+ // ============================================================================
50
+ // Usage Types
51
+ // ============================================================================
52
+
53
+ export interface TokenUsage {
54
+ inputTokens: number
55
+ outputTokens: number
56
+ cacheReadTokens?: number
57
+ cacheWriteTokens?: number
58
+ }
59
+
60
+ export interface UsageEntry {
61
+ taskId: string
62
+ model: string
63
+ usage: TokenUsage
64
+ cost: number
65
+ timestamp: Date
66
+ duration?: number // in seconds
67
+ }
68
+
69
+ export interface UsageSummary {
70
+ totalInputTokens: number
71
+ totalOutputTokens: number
72
+ totalCacheReadTokens: number
73
+ totalCacheWriteTokens: number
74
+ totalCost: number
75
+ taskCount: number
76
+ entries: UsageEntry[]
77
+ }
78
+
79
+ export interface DailySummary extends UsageSummary {
80
+ date: string // YYYY-MM-DD
81
+ }
82
+
83
+ // ============================================================================
84
+ // Cost Tracker
85
+ // ============================================================================
86
+
87
+ export class CostTracker {
88
+ private entries: UsageEntry[] = []
89
+ private storageFile: string
90
+ private namespace: string
91
+
92
+ constructor(projectRoot: string, namespace = 'default') {
93
+ this.namespace = namespace
94
+ const suffix = namespace === 'default' ? '' : `-${namespace}`
95
+ this.storageFile = path.join(projectRoot, `.loopwork-cost-tracking${suffix}.json`)
96
+ this.load()
97
+ }
98
+
99
+ /**
100
+ * Calculate cost for token usage
101
+ */
102
+ calculateCost(model: string, usage: TokenUsage): number {
103
+ const pricing = MODEL_PRICING[model] || MODEL_PRICING['default']
104
+
105
+ let cost = 0
106
+ cost += (usage.inputTokens / 1_000_000) * pricing.inputPer1M
107
+ cost += (usage.outputTokens / 1_000_000) * pricing.outputPer1M
108
+
109
+ if (usage.cacheReadTokens && pricing.cacheReadPer1M) {
110
+ cost += (usage.cacheReadTokens / 1_000_000) * pricing.cacheReadPer1M
111
+ }
112
+ if (usage.cacheWriteTokens && pricing.cacheWritePer1M) {
113
+ cost += (usage.cacheWriteTokens / 1_000_000) * pricing.cacheWritePer1M
114
+ }
115
+
116
+ return cost
117
+ }
118
+
119
+ /**
120
+ * Record token usage for a task
121
+ */
122
+ record(taskId: string, model: string, usage: TokenUsage, duration?: number): UsageEntry {
123
+ const cost = this.calculateCost(model, usage)
124
+
125
+ const entry: UsageEntry = {
126
+ taskId,
127
+ model,
128
+ usage,
129
+ cost,
130
+ timestamp: new Date(),
131
+ duration,
132
+ }
133
+
134
+ this.entries.push(entry)
135
+ this.save()
136
+
137
+ return entry
138
+ }
139
+
140
+ /**
141
+ * Parse token usage from CLI output
142
+ * Supports various CLI output formats
143
+ */
144
+ parseUsageFromOutput(output: string): TokenUsage | null {
145
+ // Claude Code format: "Tokens: 1234 input, 567 output"
146
+ const claudeMatch = output.match(/Tokens:\s*(\d+)\s*input,\s*(\d+)\s*output/i)
147
+ if (claudeMatch) {
148
+ return {
149
+ inputTokens: parseInt(claudeMatch[1], 10),
150
+ outputTokens: parseInt(claudeMatch[2], 10),
151
+ }
152
+ }
153
+
154
+ // OpenCode format: "Usage: 1234 prompt tokens, 567 completion tokens"
155
+ const openCodeMatch = output.match(/Usage:\s*(\d+)\s*prompt\s*tokens?,\s*(\d+)\s*completion\s*tokens?/i)
156
+ if (openCodeMatch) {
157
+ return {
158
+ inputTokens: parseInt(openCodeMatch[1], 10),
159
+ outputTokens: parseInt(openCodeMatch[2], 10),
160
+ }
161
+ }
162
+
163
+ // Generic format: "input_tokens: 1234, output_tokens: 567"
164
+ const genericMatch = output.match(/input[_\s]tokens?:\s*(\d+).*output[_\s]tokens?:\s*(\d+)/i)
165
+ if (genericMatch) {
166
+ return {
167
+ inputTokens: parseInt(genericMatch[1], 10),
168
+ outputTokens: parseInt(genericMatch[2], 10),
169
+ }
170
+ }
171
+
172
+ // JSON format in output
173
+ const jsonMatch = output.match(/\{[^}]*"input[_\s]?tokens?":\s*(\d+)[^}]*"output[_\s]?tokens?":\s*(\d+)[^}]*\}/i)
174
+ if (jsonMatch) {
175
+ return {
176
+ inputTokens: parseInt(jsonMatch[1], 10),
177
+ outputTokens: parseInt(jsonMatch[2], 10),
178
+ }
179
+ }
180
+
181
+ return null
182
+ }
183
+
184
+ /**
185
+ * Get summary for a specific task
186
+ */
187
+ getTaskSummary(taskId: string): UsageSummary {
188
+ const taskEntries = this.entries.filter(e => e.taskId === taskId)
189
+ return this.summarize(taskEntries)
190
+ }
191
+
192
+ /**
193
+ * Get summary for today
194
+ */
195
+ getTodaySummary(): DailySummary {
196
+ const today = new Date().toISOString().split('T')[0]
197
+ const todayEntries = this.entries.filter(e =>
198
+ e.timestamp.toISOString().split('T')[0] === today
199
+ )
200
+
201
+ return {
202
+ date: today,
203
+ ...this.summarize(todayEntries),
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Get summary for a date range
209
+ */
210
+ getRangeSummary(startDate: Date, endDate: Date): UsageSummary {
211
+ const rangeEntries = this.entries.filter(e =>
212
+ e.timestamp >= startDate && e.timestamp <= endDate
213
+ )
214
+ return this.summarize(rangeEntries)
215
+ }
216
+
217
+ /**
218
+ * Get all-time summary
219
+ */
220
+ getAllTimeSummary(): UsageSummary {
221
+ return this.summarize(this.entries)
222
+ }
223
+
224
+ /**
225
+ * Get daily summaries for the last N days
226
+ */
227
+ getDailySummaries(days = 7): DailySummary[] {
228
+ const summaries: DailySummary[] = []
229
+
230
+ for (let i = 0; i < days; i++) {
231
+ const date = new Date()
232
+ date.setDate(date.getDate() - i)
233
+ const dateStr = date.toISOString().split('T')[0]
234
+
235
+ const dayEntries = this.entries.filter(e =>
236
+ e.timestamp.toISOString().split('T')[0] === dateStr
237
+ )
238
+
239
+ summaries.push({
240
+ date: dateStr,
241
+ ...this.summarize(dayEntries),
242
+ })
243
+ }
244
+
245
+ return summaries
246
+ }
247
+
248
+ /**
249
+ * Get cost breakdown by model
250
+ */
251
+ getCostByModel(): Record<string, { cost: number; tokens: TokenUsage }> {
252
+ const byModel: Record<string, { cost: number; tokens: TokenUsage }> = {}
253
+
254
+ for (const entry of this.entries) {
255
+ if (!byModel[entry.model]) {
256
+ byModel[entry.model] = {
257
+ cost: 0,
258
+ tokens: { inputTokens: 0, outputTokens: 0 },
259
+ }
260
+ }
261
+
262
+ byModel[entry.model].cost += entry.cost
263
+ byModel[entry.model].tokens.inputTokens += entry.usage.inputTokens
264
+ byModel[entry.model].tokens.outputTokens += entry.usage.outputTokens
265
+ }
266
+
267
+ return byModel
268
+ }
269
+
270
+ /**
271
+ * Clear all entries
272
+ */
273
+ clear(): void {
274
+ this.entries = []
275
+ this.save()
276
+ }
277
+
278
+ private summarize(entries: UsageEntry[]): UsageSummary {
279
+ return entries.reduce(
280
+ (acc, entry) => ({
281
+ totalInputTokens: acc.totalInputTokens + entry.usage.inputTokens,
282
+ totalOutputTokens: acc.totalOutputTokens + entry.usage.outputTokens,
283
+ totalCacheReadTokens: acc.totalCacheReadTokens + (entry.usage.cacheReadTokens || 0),
284
+ totalCacheWriteTokens: acc.totalCacheWriteTokens + (entry.usage.cacheWriteTokens || 0),
285
+ totalCost: acc.totalCost + entry.cost,
286
+ taskCount: acc.taskCount + 1,
287
+ entries: [...acc.entries, entry],
288
+ }),
289
+ {
290
+ totalInputTokens: 0,
291
+ totalOutputTokens: 0,
292
+ totalCacheReadTokens: 0,
293
+ totalCacheWriteTokens: 0,
294
+ totalCost: 0,
295
+ taskCount: 0,
296
+ entries: [] as UsageEntry[],
297
+ }
298
+ )
299
+ }
300
+
301
+ private load(): void {
302
+ try {
303
+ if (fs.existsSync(this.storageFile)) {
304
+ const content = fs.readFileSync(this.storageFile, 'utf-8')
305
+ const data = JSON.parse(content)
306
+ this.entries = data.entries.map((e: any) => ({
307
+ ...e,
308
+ timestamp: new Date(e.timestamp),
309
+ }))
310
+ }
311
+ } catch {
312
+ this.entries = []
313
+ }
314
+ }
315
+
316
+ private save(): void {
317
+ try {
318
+ const data = { entries: this.entries, namespace: this.namespace }
319
+ fs.writeFileSync(this.storageFile, JSON.stringify(data, null, 2))
320
+ } catch {
321
+ // Ignore save errors
322
+ }
323
+ }
324
+ }
325
+
326
+ // ============================================================================
327
+ // Cost Tracking Hook Plugin
328
+ // ============================================================================
329
+
330
+ export function createCostTrackingPlugin(
331
+ projectRoot: string,
332
+ namespace = 'default',
333
+ defaultModel = 'claude-3.5-sonnet'
334
+ ): LoopworkPlugin {
335
+ const tracker = new CostTracker(projectRoot, namespace)
336
+
337
+ return {
338
+ name: 'cost-tracking',
339
+
340
+ async onTaskStart(task: PluginTask) {
341
+ // Track task start time (stored in tracker)
342
+ },
343
+
344
+ async onTaskComplete(task: PluginTask, result: { output?: string; duration: number }) {
345
+ const output = (result as any).output || ''
346
+ const usage = tracker.parseUsageFromOutput(output)
347
+
348
+ if (usage) {
349
+ tracker.record(task.id, defaultModel, usage, result.duration)
350
+ }
351
+ },
352
+
353
+ async onLoopEnd(stats: LoopStats) {
354
+ const summary = tracker.getTodaySummary()
355
+ console.log(`\n📊 Cost Summary for today:`)
356
+ console.log(` Tasks: ${summary.taskCount}`)
357
+ console.log(` Tokens: ${summary.totalInputTokens.toLocaleString()} in / ${summary.totalOutputTokens.toLocaleString()} out`)
358
+ console.log(` Cost: $${summary.totalCost.toFixed(4)}`)
359
+ },
360
+ }
361
+ }
362
+
363
+ // ============================================================================
364
+ // Formatting Helpers
365
+ // ============================================================================
366
+
367
+ export function formatCost(cost: number): string {
368
+ if (cost < 0.01) {
369
+ return `$${cost.toFixed(4)}`
370
+ } else if (cost < 1) {
371
+ return `$${cost.toFixed(3)}`
372
+ } else {
373
+ return `$${cost.toFixed(2)}`
374
+ }
375
+ }
376
+
377
+ export function formatTokens(tokens: number): string {
378
+ if (tokens >= 1_000_000) {
379
+ return `${(tokens / 1_000_000).toFixed(2)}M`
380
+ } else if (tokens >= 1_000) {
381
+ return `${(tokens / 1_000).toFixed(1)}K`
382
+ }
383
+ return tokens.toString()
384
+ }
385
+
386
+ export function formatUsageSummary(summary: UsageSummary): string {
387
+ const lines = [
388
+ `Tasks: ${summary.taskCount}`,
389
+ `Input tokens: ${formatTokens(summary.totalInputTokens)}`,
390
+ `Output tokens: ${formatTokens(summary.totalOutputTokens)}`,
391
+ `Total cost: ${formatCost(summary.totalCost)}`,
392
+ ]
393
+
394
+ if (summary.totalCacheReadTokens > 0) {
395
+ lines.push(`Cache read: ${formatTokens(summary.totalCacheReadTokens)}`)
396
+ }
397
+ if (summary.totalCacheWriteTokens > 0) {
398
+ lines.push(`Cache write: ${formatTokens(summary.totalCacheWriteTokens)}`)
399
+ }
400
+
401
+ return lines.join('\n')
402
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Discord Plugin for Loopwork
3
+ *
4
+ * Sends notifications to Discord channels via webhooks.
5
+ *
6
+ * Setup:
7
+ * 1. Create a webhook in Discord: Server Settings > Integrations > Webhooks
8
+ * 2. Copy the webhook URL
9
+ * 3. Set DISCORD_WEBHOOK_URL env var
10
+ */
11
+
12
+ import type { LoopworkPlugin, PluginTask } from '../contracts'
13
+
14
+ export interface DiscordConfig {
15
+ webhookUrl?: string
16
+ /** Username to display for bot messages */
17
+ username?: string
18
+ /** Avatar URL for bot messages */
19
+ avatarUrl?: string
20
+ /** Send notification when task starts */
21
+ notifyOnStart?: boolean
22
+ /** Send notification when task completes */
23
+ notifyOnComplete?: boolean
24
+ /** Send notification when task fails */
25
+ notifyOnFail?: boolean
26
+ /** Send summary when loop ends */
27
+ notifyOnLoopEnd?: boolean
28
+ /** Mention role/user on failures (e.g., "<@&123456>" for role, "<@123456>" for user) */
29
+ mentionOnFail?: string
30
+ }
31
+
32
+ interface DiscordEmbed {
33
+ title?: string
34
+ description?: string
35
+ color?: number
36
+ fields?: Array<{ name: string; value: string; inline?: boolean }>
37
+ timestamp?: string
38
+ footer?: { text: string }
39
+ }
40
+
41
+ interface DiscordWebhookPayload {
42
+ content?: string
43
+ username?: string
44
+ avatar_url?: string
45
+ embeds?: DiscordEmbed[]
46
+ }
47
+
48
+ // Discord embed colors
49
+ const COLORS = {
50
+ blue: 0x3498db, // info/start
51
+ green: 0x2ecc71, // success
52
+ red: 0xe74c3c, // error
53
+ yellow: 0xf1c40f, // warning
54
+ purple: 0x9b59b6, // summary
55
+ }
56
+
57
+ export class DiscordClient {
58
+ private webhookUrl: string
59
+ private username?: string
60
+ private avatarUrl?: string
61
+
62
+ constructor(webhookUrl: string, options?: { username?: string; avatarUrl?: string }) {
63
+ this.webhookUrl = webhookUrl
64
+ this.username = options?.username
65
+ this.avatarUrl = options?.avatarUrl
66
+ }
67
+
68
+ /**
69
+ * Send a message to Discord
70
+ */
71
+ async send(payload: DiscordWebhookPayload): Promise<void> {
72
+ const body: DiscordWebhookPayload = {
73
+ ...payload,
74
+ username: payload.username || this.username,
75
+ avatar_url: payload.avatar_url || this.avatarUrl,
76
+ }
77
+
78
+ const response = await fetch(this.webhookUrl, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify(body),
82
+ })
83
+
84
+ if (!response.ok) {
85
+ const error = await response.text()
86
+ throw new Error(`Discord webhook error: ${response.status} - ${error}`)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Send a simple text message
92
+ */
93
+ async sendText(content: string): Promise<void> {
94
+ await this.send({ content })
95
+ }
96
+
97
+ /**
98
+ * Send an embed message
99
+ */
100
+ async sendEmbed(embed: DiscordEmbed): Promise<void> {
101
+ await this.send({ embeds: [embed] })
102
+ }
103
+
104
+ /**
105
+ * Send task started notification
106
+ */
107
+ async notifyTaskStart(task: PluginTask): Promise<void> {
108
+ await this.sendEmbed({
109
+ title: `🔄 Task Started`,
110
+ description: `**${task.id}**: ${task.title}`,
111
+ color: COLORS.blue,
112
+ timestamp: new Date().toISOString(),
113
+ })
114
+ }
115
+
116
+ /**
117
+ * Send task completed notification
118
+ */
119
+ async notifyTaskComplete(task: PluginTask, duration: number): Promise<void> {
120
+ await this.sendEmbed({
121
+ title: `✅ Task Completed`,
122
+ description: `**${task.id}**: ${task.title}`,
123
+ color: COLORS.green,
124
+ fields: [
125
+ { name: 'Duration', value: formatDuration(duration), inline: true },
126
+ ],
127
+ timestamp: new Date().toISOString(),
128
+ })
129
+ }
130
+
131
+ /**
132
+ * Send task failed notification
133
+ */
134
+ async notifyTaskFailed(task: PluginTask, error: string, mention?: string): Promise<void> {
135
+ await this.send({
136
+ content: mention ? `${mention} Task failed!` : undefined,
137
+ embeds: [{
138
+ title: `❌ Task Failed`,
139
+ description: `**${task.id}**: ${task.title}`,
140
+ color: COLORS.red,
141
+ fields: [
142
+ { name: 'Error', value: error.slice(0, 1000) },
143
+ ],
144
+ timestamp: new Date().toISOString(),
145
+ }],
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Send loop summary notification
151
+ */
152
+ async notifyLoopEnd(stats: { completed: number; failed: number; duration: number }): Promise<void> {
153
+ const color = stats.failed > 0 ? COLORS.yellow : COLORS.green
154
+ await this.sendEmbed({
155
+ title: `📊 Loop Summary`,
156
+ color,
157
+ fields: [
158
+ { name: 'Completed', value: `${stats.completed}`, inline: true },
159
+ { name: 'Failed', value: `${stats.failed}`, inline: true },
160
+ { name: 'Duration', value: formatDuration(stats.duration), inline: true },
161
+ ],
162
+ timestamp: new Date().toISOString(),
163
+ })
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Format seconds to human readable duration
169
+ */
170
+ function formatDuration(seconds: number): string {
171
+ if (seconds < 60) return `${Math.round(seconds)}s`
172
+ const mins = Math.floor(seconds / 60)
173
+ const secs = Math.round(seconds % 60)
174
+ if (mins < 60) return `${mins}m ${secs}s`
175
+ const hours = Math.floor(mins / 60)
176
+ const remainMins = mins % 60
177
+ return `${hours}h ${remainMins}m`
178
+ }
179
+
180
+ /**
181
+ * Create Discord plugin wrapper
182
+ */
183
+ export function withDiscord(config: DiscordConfig = {}) {
184
+ const webhookUrl = config.webhookUrl || process.env.DISCORD_WEBHOOK_URL
185
+
186
+ return (baseConfig: any) => ({
187
+ ...baseConfig,
188
+ discord: {
189
+ webhookUrl,
190
+ username: config.username || 'Loopwork',
191
+ avatarUrl: config.avatarUrl,
192
+ notifyOnStart: config.notifyOnStart ?? false,
193
+ notifyOnComplete: config.notifyOnComplete ?? true,
194
+ notifyOnFail: config.notifyOnFail ?? true,
195
+ notifyOnLoopEnd: config.notifyOnLoopEnd ?? true,
196
+ mentionOnFail: config.mentionOnFail,
197
+ },
198
+ })
199
+ }
200
+
201
+ /**
202
+ * Create Discord hook plugin
203
+ */
204
+ export function createDiscordPlugin(config: DiscordConfig = {}): LoopworkPlugin {
205
+ const webhookUrl = config.webhookUrl || process.env.DISCORD_WEBHOOK_URL || ''
206
+ const notifyOnStart = config.notifyOnStart ?? false
207
+ const notifyOnComplete = config.notifyOnComplete ?? true
208
+ const notifyOnFail = config.notifyOnFail ?? true
209
+ const notifyOnLoopEnd = config.notifyOnLoopEnd ?? true
210
+
211
+ if (!webhookUrl) {
212
+ return {
213
+ name: 'discord',
214
+ onConfigLoad: (cfg) => {
215
+ console.warn('Discord plugin: Missing DISCORD_WEBHOOK_URL')
216
+ return cfg
217
+ },
218
+ }
219
+ }
220
+
221
+ const client = new DiscordClient(webhookUrl, {
222
+ username: config.username || 'Loopwork',
223
+ avatarUrl: config.avatarUrl,
224
+ })
225
+
226
+ return {
227
+ name: 'discord',
228
+
229
+ async onTaskStart(task) {
230
+ if (!notifyOnStart) return
231
+
232
+ try {
233
+ await client.notifyTaskStart(task)
234
+ } catch (e: any) {
235
+ console.warn(`Discord: Failed to send notification: ${e.message}`)
236
+ }
237
+ },
238
+
239
+ async onTaskComplete(task, result) {
240
+ if (!notifyOnComplete) return
241
+
242
+ try {
243
+ await client.notifyTaskComplete(task, result.duration)
244
+ } catch (e: any) {
245
+ console.warn(`Discord: Failed to send notification: ${e.message}`)
246
+ }
247
+ },
248
+
249
+ async onTaskFailed(task, error) {
250
+ if (!notifyOnFail) return
251
+
252
+ try {
253
+ await client.notifyTaskFailed(task, error, config.mentionOnFail)
254
+ } catch (e: any) {
255
+ console.warn(`Discord: Failed to send notification: ${e.message}`)
256
+ }
257
+ },
258
+
259
+ async onLoopEnd(stats) {
260
+ if (!notifyOnLoopEnd) return
261
+
262
+ try {
263
+ await client.notifyLoopEnd(stats)
264
+ } catch (e: any) {
265
+ console.warn(`Discord: Failed to send notification: ${e.message}`)
266
+ }
267
+ },
268
+ }
269
+ }