@geekbeer/minion 3.6.4 → 3.8.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/core/db.js CHANGED
@@ -241,6 +241,60 @@ function initSchema(db) {
241
241
 
242
242
  CREATE INDEX IF NOT EXISTS idx_workflows_name ON workflows(name);
243
243
 
244
+ -- ==================== todos ====================
245
+ CREATE TABLE IF NOT EXISTS todos (
246
+ id TEXT PRIMARY KEY,
247
+ title TEXT NOT NULL,
248
+ description TEXT,
249
+ status TEXT NOT NULL DEFAULT 'pending'
250
+ CHECK (status IN ('pending', 'in_progress', 'done', 'cancelled')),
251
+ priority TEXT NOT NULL DEFAULT 'normal'
252
+ CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
253
+ source_type TEXT
254
+ CHECK (source_type IS NULL OR source_type IN ('thread', 'workflow', 'directive', 'user', 'self')),
255
+ source_id TEXT,
256
+ project_id TEXT,
257
+ due_at TEXT,
258
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
259
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
260
+ completed_at TEXT,
261
+ data TEXT
262
+ );
263
+
264
+ CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status);
265
+ CREATE INDEX IF NOT EXISTS idx_todos_project ON todos(project_id);
266
+ CREATE INDEX IF NOT EXISTS idx_todos_priority ON todos(priority);
267
+
268
+ -- ==================== emails ====================
269
+ CREATE TABLE IF NOT EXISTS emails (
270
+ id TEXT PRIMARY KEY,
271
+ from_address TEXT NOT NULL,
272
+ to_address TEXT NOT NULL,
273
+ subject TEXT DEFAULT '',
274
+ body_text TEXT DEFAULT '',
275
+ body_html TEXT DEFAULT '',
276
+ received_at TEXT NOT NULL,
277
+ is_read INTEGER DEFAULT 0,
278
+ labels TEXT DEFAULT '[]'
279
+ );
280
+
281
+ CREATE INDEX IF NOT EXISTS idx_emails_received_at ON emails(received_at DESC);
282
+ CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_address);
283
+ CREATE INDEX IF NOT EXISTS idx_emails_is_read ON emails(is_read);
284
+
285
+ -- ==================== email_attachments ====================
286
+ CREATE TABLE IF NOT EXISTS email_attachments (
287
+ id TEXT PRIMARY KEY,
288
+ email_id TEXT NOT NULL REFERENCES emails(id) ON DELETE CASCADE,
289
+ filename TEXT NOT NULL,
290
+ content_type TEXT,
291
+ size_bytes INTEGER DEFAULT 0,
292
+ approved INTEGER DEFAULT 0,
293
+ data BLOB
294
+ );
295
+
296
+ CREATE INDEX IF NOT EXISTS idx_email_attachments_email ON email_attachments(email_id);
297
+
244
298
  -- ==================== schema_version ====================
245
299
  CREATE TABLE IF NOT EXISTS schema_version (
246
300
  version INTEGER PRIMARY KEY,
@@ -334,6 +388,43 @@ function migrateSchema(db) {
334
388
 
335
389
  console.log('[DB] Migration 1 complete: FTS5 trigram tokenizer applied')
336
390
  }
391
+
392
+ if (currentVersion < 2) {
393
+ console.log('[DB] Migration 2: Adding emails FTS5...')
394
+
395
+ db.exec(`
396
+ CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
397
+ subject,
398
+ body_text,
399
+ content=emails,
400
+ content_rowid=rowid,
401
+ tokenize='trigram'
402
+ );
403
+
404
+ CREATE TRIGGER IF NOT EXISTS emails_ai AFTER INSERT ON emails BEGIN
405
+ INSERT INTO emails_fts(rowid, subject, body_text)
406
+ VALUES (new.rowid, new.subject, new.body_text);
407
+ END;
408
+
409
+ CREATE TRIGGER IF NOT EXISTS emails_ad AFTER DELETE ON emails BEGIN
410
+ INSERT INTO emails_fts(emails_fts, rowid, subject, body_text)
411
+ VALUES ('delete', old.rowid, old.subject, old.body_text);
412
+ END;
413
+
414
+ CREATE TRIGGER IF NOT EXISTS emails_au AFTER UPDATE ON emails BEGIN
415
+ INSERT INTO emails_fts(emails_fts, rowid, subject, body_text)
416
+ VALUES ('delete', old.rowid, old.subject, old.body_text);
417
+ INSERT INTO emails_fts(rowid, subject, body_text)
418
+ VALUES (new.rowid, new.subject, new.body_text);
419
+ END;
420
+
421
+ INSERT INTO emails_fts(emails_fts) VALUES ('rebuild');
422
+
423
+ INSERT INTO schema_version (version) VALUES (2);
424
+ `)
425
+
426
+ console.log('[DB] Migration 2 complete: emails FTS5 added')
427
+ }
337
428
  }
338
429
 
339
430
  /**
@@ -55,10 +55,18 @@ function pushToHQ() {
55
55
  debounceTimer = null
56
56
  try {
57
57
  const { currentStatus, currentTask } = getStatusFn()
58
+
59
+ // Include todo summary if store is available
60
+ let todo_summary = null
61
+ try {
62
+ todo_summary = require('../stores/todo-store').getSummary()
63
+ } catch { /* store not yet initialized */ }
64
+
58
65
  getSendHeartbeat()({
59
66
  status: currentStatus,
60
67
  current_task: currentTask,
61
68
  running_tasks: tasks,
69
+ todo_summary,
62
70
  version: getVersion(),
63
71
  }).catch(err => {
64
72
  console.error('[RunningTasks] Heartbeat push failed:', err.message)
@@ -240,7 +240,8 @@ ${messageHistory || '(メッセージなし)'}
240
240
  {
241
241
  "should_respond": true/false,
242
242
  "reason": "判断の理由(1行)",
243
- "response": "返信内容(should_respondがtrueの場合のみ)"
243
+ "response": "返信内容(should_respondがtrueの場合のみ)",
244
+ "todo": { "title": "タスク名", "priority": "normal|high|urgent", "due_at": "YYYY-MM-DD" }
244
245
  }
245
246
 
246
247
  判断基準:
@@ -249,7 +250,8 @@ ${messageHistory || '(メッセージなし)'}
249
250
  ${isWorkspace ? '- ワークスペーススレッドではすべてのミニオンが参加可能。自分が貢献できる場合は積極的に参加する' : `- 自分のロール(${myProject.role})に関連する話題か`}
250
251
  - 自分が貢献できる知見や意見があるか
251
252
  - 既に十分な回答がある場合は重複を避ける
252
- - 人間に聞くべき場合は @user メンションを含めて返信する`
253
+ - 人間に聞くべき場合は @user メンションを含めて返信する
254
+ - あなたが具体的なアクション(調査、修正、確認など)を引き受ける場合は、todoフィールドにタスクを記録する(オプション。単なる情報提供や質問への回答では不要)`
253
255
 
254
256
  try {
255
257
  const result = await llmCallFn(prompt)
@@ -282,6 +284,24 @@ ${isWorkspace ? '- ワークスペーススレッドではすべてのミニオ
282
284
  } else {
283
285
  console.log(`[ThreadWatcher] Skipped thread "${threadDetail.title}" (reason: ${parsed.reason || 'not relevant'})`)
284
286
  }
287
+
288
+ // Auto-create TODO if LLM detected a commitment
289
+ if (parsed.todo && parsed.todo.title) {
290
+ try {
291
+ const todoStore = require('../stores/todo-store')
292
+ todoStore.add({
293
+ title: parsed.todo.title,
294
+ priority: parsed.todo.priority || 'normal',
295
+ due_at: parsed.todo.due_at || null,
296
+ source_type: 'thread',
297
+ source_id: threadSummary.id,
298
+ project_id: threadSummary.project_id || null,
299
+ })
300
+ console.log(`[ThreadWatcher] Created TODO from thread: "${parsed.todo.title}"`)
301
+ } catch (err) {
302
+ console.error(`[ThreadWatcher] Failed to create TODO: ${err.message}`)
303
+ }
304
+ }
285
305
  } catch (err) {
286
306
  console.error(`[ThreadWatcher] LLM evaluation failed for thread ${threadSummary.id}: ${err.message}`)
287
307
  }
@@ -159,16 +159,19 @@ function checkVnc() {
159
159
  }
160
160
  }
161
161
 
162
- // Linux: Xvfb + x11vnc + websockify (noVNC)
162
+ // Linux: Xvfb + VNC server (x0vncserver or x11vnc) + websockify (noVNC)
163
163
  const xvfb = isProcessRunning('Xvfb')
164
- const vnc = isProcessRunning('x11vnc')
164
+ const x0vnc = isProcessRunning('x0vncserver') || isProcessRunning('X0tigervnc')
165
+ const x11vnc = isProcessRunning('x11vnc')
166
+ const vncServer = x0vnc || x11vnc
167
+ const vncBackend = isPortListening(5900)
165
168
  const websockify = isPortListening(6080)
166
169
  return {
167
- running: xvfb && websockify,
170
+ running: xvfb && vncBackend && websockify,
168
171
  details: [
169
- `Xvfb: ${xvfb ? 'running' : 'not running'}`,
170
- `x11vnc: ${vnc ? 'running' : 'not running'}`,
171
- `websockify (:6080): ${websockify ? 'listening' : 'not listening'}`,
172
+ `Xvfb: ${xvfb ? 'running' : 'NOT RUNNING'}`,
173
+ `VNC server (:5900): ${vncServer ? 'running' : 'NOT RUNNING'}${vncBackend ? '' : ' (port not listening)'}`,
174
+ `websockify (:6080): ${websockify ? 'listening' : 'NOT LISTENING'}`,
172
175
  ].join(', '),
173
176
  }
174
177
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Email management endpoints
3
+ *
4
+ * Provides inbox API for the minion's local email storage.
5
+ * Emails are received from Cloudflare Email Worker (POST) and
6
+ * read by Claude Code or HQ dashboard (GET).
7
+ *
8
+ * Endpoints:
9
+ * POST /api/email/inbox - Receive email from Worker
10
+ * GET /api/email/inbox - List emails (with filters)
11
+ * GET /api/email/inbox/summary - Unread/total counts
12
+ * GET /api/email/inbox/:id - Get single email
13
+ * PUT /api/email/inbox/:id - Update (read/unread/labels)
14
+ * DELETE /api/email/inbox/:id - Delete email
15
+ * POST /api/email/inbox/:id/attachments/:attachmentId/approve - Approve attachment
16
+ * GET /api/email/inbox/:id/attachments/:attachmentId - Get attachment
17
+ */
18
+
19
+ const { verifyToken } = require('../lib/auth')
20
+ const emailStore = require('../stores/email-store')
21
+
22
+ /**
23
+ * Register email routes as Fastify plugin
24
+ * @param {import('fastify').FastifyInstance} fastify
25
+ */
26
+ async function emailRoutes(fastify) {
27
+
28
+ // GET /api/email/inbox/summary - Unread/total counts (must be before /:id)
29
+ fastify.get('/api/email/inbox/summary', async (request, reply) => {
30
+ if (!verifyToken(request)) {
31
+ reply.code(401)
32
+ return { success: false, error: 'Unauthorized' }
33
+ }
34
+
35
+ const summary = emailStore.getSummary()
36
+ return { success: true, summary }
37
+ })
38
+
39
+ // GET /api/email/inbox - List emails with optional filters
40
+ fastify.get('/api/email/inbox', async (request, reply) => {
41
+ if (!verifyToken(request)) {
42
+ reply.code(401)
43
+ return { success: false, error: 'Unauthorized' }
44
+ }
45
+
46
+ const { is_read, from_address, search, limit } = request.query || {}
47
+ const emails = emailStore.list({
48
+ is_read,
49
+ from_address,
50
+ search,
51
+ limit: limit ? parseInt(limit, 10) : undefined,
52
+ })
53
+
54
+ return { success: true, emails }
55
+ })
56
+
57
+ // GET /api/email/inbox/:id - Get single email
58
+ fastify.get('/api/email/inbox/:id', async (request, reply) => {
59
+ if (!verifyToken(request)) {
60
+ reply.code(401)
61
+ return { success: false, error: 'Unauthorized' }
62
+ }
63
+
64
+ const email = emailStore.getById(request.params.id)
65
+ if (!email) {
66
+ reply.code(404)
67
+ return { success: false, error: 'Email not found' }
68
+ }
69
+
70
+ return { success: true, email }
71
+ })
72
+
73
+ // POST /api/email/inbox - Receive email from Cloudflare Email Worker
74
+ fastify.post('/api/email/inbox', async (request, reply) => {
75
+ if (!verifyToken(request)) {
76
+ reply.code(401)
77
+ return { success: false, error: 'Unauthorized' }
78
+ }
79
+
80
+ const { from_address, to_address, subject, body_text, body_html, labels, attachments } = request.body || {}
81
+ if (!from_address || !to_address) {
82
+ reply.code(400)
83
+ return { success: false, error: 'from_address and to_address are required' }
84
+ }
85
+
86
+ try {
87
+ const email = emailStore.add({ from_address, to_address, subject, body_text, body_html, labels, attachments })
88
+ console.log(`[Emails] Received: "${email.subject}" from ${email.from_address}`)
89
+ reply.code(201)
90
+ return { success: true, email }
91
+ } catch (err) {
92
+ reply.code(400)
93
+ return { success: false, error: err.message }
94
+ }
95
+ })
96
+
97
+ // PUT /api/email/inbox/:id - Update email (read/unread, labels)
98
+ fastify.put('/api/email/inbox/:id', async (request, reply) => {
99
+ if (!verifyToken(request)) {
100
+ reply.code(401)
101
+ return { success: false, error: 'Unauthorized' }
102
+ }
103
+
104
+ const { is_read } = request.body || {}
105
+ const id = request.params.id
106
+
107
+ if (is_read === 1 || is_read === true) {
108
+ const updated = emailStore.markAsRead(id)
109
+ if (!updated) {
110
+ reply.code(404)
111
+ return { success: false, error: 'Email not found' }
112
+ }
113
+ } else if (is_read === 0 || is_read === false) {
114
+ const updated = emailStore.markAsUnread(id)
115
+ if (!updated) {
116
+ reply.code(404)
117
+ return { success: false, error: 'Email not found' }
118
+ }
119
+ }
120
+
121
+ const email = emailStore.getById(id)
122
+ if (!email) {
123
+ reply.code(404)
124
+ return { success: false, error: 'Email not found' }
125
+ }
126
+
127
+ return { success: true, email }
128
+ })
129
+
130
+ // DELETE /api/email/inbox/:id - Delete email
131
+ fastify.delete('/api/email/inbox/:id', async (request, reply) => {
132
+ if (!verifyToken(request)) {
133
+ reply.code(401)
134
+ return { success: false, error: 'Unauthorized' }
135
+ }
136
+
137
+ const deleted = emailStore.remove(request.params.id)
138
+ if (!deleted) {
139
+ reply.code(404)
140
+ return { success: false, error: 'Email not found' }
141
+ }
142
+
143
+ console.log(`[Emails] Deleted: ${request.params.id}`)
144
+ return { success: true }
145
+ })
146
+
147
+ // POST /api/email/inbox/:id/attachments/:attachmentId/approve - Approve attachment
148
+ fastify.post('/api/email/inbox/:id/attachments/:attachmentId/approve', async (request, reply) => {
149
+ if (!verifyToken(request)) {
150
+ reply.code(401)
151
+ return { success: false, error: 'Unauthorized' }
152
+ }
153
+
154
+ const approved = emailStore.approveAttachment(request.params.attachmentId)
155
+ if (!approved) {
156
+ reply.code(404)
157
+ return { success: false, error: 'Attachment not found' }
158
+ }
159
+
160
+ console.log(`[Emails] Attachment approved: ${request.params.attachmentId}`)
161
+ return { success: true }
162
+ })
163
+
164
+ // GET /api/email/inbox/:id/attachments/:attachmentId - Get attachment
165
+ fastify.get('/api/email/inbox/:id/attachments/:attachmentId', async (request, reply) => {
166
+ if (!verifyToken(request)) {
167
+ reply.code(401)
168
+ return { success: false, error: 'Unauthorized' }
169
+ }
170
+
171
+ const attachment = emailStore.getAttachment(request.params.attachmentId)
172
+ if (!attachment) {
173
+ reply.code(404)
174
+ return { success: false, error: 'Attachment not found' }
175
+ }
176
+
177
+ if (!attachment.approved) {
178
+ return { success: true, attachment: { id: attachment.id, filename: attachment.filename, content_type: attachment.content_type, size_bytes: attachment.size_bytes, approved: false } }
179
+ }
180
+
181
+ // Return binary data with correct content type
182
+ reply.header('Content-Type', attachment.content_type || 'application/octet-stream')
183
+ reply.header('Content-Disposition', `attachment; filename="${attachment.filename}"`)
184
+ return reply.send(attachment.data)
185
+ })
186
+ }
187
+
188
+ module.exports = { emailRoutes }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * TODO management endpoints
3
+ *
4
+ * Provides CRUD for the minion's local TODO list.
5
+ * TODOs are stored in SQLite and synced to HQ via heartbeat summary.
6
+ *
7
+ * Endpoints:
8
+ * GET /api/todos - List todos (with optional filters)
9
+ * GET /api/todos/summary - Get status counts
10
+ * GET /api/todos/:id - Get a single todo
11
+ * POST /api/todos - Create a new todo
12
+ * PUT /api/todos/:id - Update a todo
13
+ * DELETE /api/todos/:id - Delete a todo
14
+ */
15
+
16
+ const { verifyToken } = require('../lib/auth')
17
+ const todoStore = require('../stores/todo-store')
18
+
19
+ /**
20
+ * Register todo routes as Fastify plugin
21
+ * @param {import('fastify').FastifyInstance} fastify
22
+ */
23
+ async function todoRoutes(fastify) {
24
+
25
+ // GET /api/todos/summary - Status counts (must be before /:id)
26
+ fastify.get('/api/todos/summary', async (request, reply) => {
27
+ if (!verifyToken(request)) {
28
+ reply.code(401)
29
+ return { success: false, error: 'Unauthorized' }
30
+ }
31
+
32
+ const summary = todoStore.getSummary()
33
+ return { success: true, summary }
34
+ })
35
+
36
+ // GET /api/todos - List with optional filters
37
+ fastify.get('/api/todos', async (request, reply) => {
38
+ if (!verifyToken(request)) {
39
+ reply.code(401)
40
+ return { success: false, error: 'Unauthorized' }
41
+ }
42
+
43
+ const { status, priority, project_id, source_type, limit } = request.query || {}
44
+ const todos = todoStore.list({
45
+ status,
46
+ priority,
47
+ project_id,
48
+ source_type,
49
+ limit: limit ? parseInt(limit, 10) : undefined,
50
+ })
51
+
52
+ return { success: true, todos }
53
+ })
54
+
55
+ // GET /api/todos/:id - Get single todo
56
+ fastify.get('/api/todos/:id', async (request, reply) => {
57
+ if (!verifyToken(request)) {
58
+ reply.code(401)
59
+ return { success: false, error: 'Unauthorized' }
60
+ }
61
+
62
+ const todo = todoStore.getById(request.params.id)
63
+ if (!todo) {
64
+ reply.code(404)
65
+ return { success: false, error: 'Todo not found' }
66
+ }
67
+
68
+ return { success: true, todo }
69
+ })
70
+
71
+ // POST /api/todos - Create a new todo
72
+ fastify.post('/api/todos', async (request, reply) => {
73
+ if (!verifyToken(request)) {
74
+ reply.code(401)
75
+ return { success: false, error: 'Unauthorized' }
76
+ }
77
+
78
+ const { title, description, priority, source_type, source_id, project_id, due_at, data } = request.body || {}
79
+ if (!title) {
80
+ reply.code(400)
81
+ return { success: false, error: 'title is required' }
82
+ }
83
+
84
+ try {
85
+ const todo = todoStore.add({ title, description, priority, source_type, source_id, project_id, due_at, data })
86
+ console.log(`[Todos] Created: ${todo.id} "${todo.title}"`)
87
+ reply.code(201)
88
+ return { success: true, todo }
89
+ } catch (err) {
90
+ reply.code(400)
91
+ return { success: false, error: err.message }
92
+ }
93
+ })
94
+
95
+ // PUT /api/todos/:id - Update a todo
96
+ fastify.put('/api/todos/:id', async (request, reply) => {
97
+ if (!verifyToken(request)) {
98
+ reply.code(401)
99
+ return { success: false, error: 'Unauthorized' }
100
+ }
101
+
102
+ try {
103
+ const todo = todoStore.update(request.params.id, request.body || {})
104
+ if (!todo) {
105
+ reply.code(404)
106
+ return { success: false, error: 'Todo not found' }
107
+ }
108
+
109
+ console.log(`[Todos] Updated: ${todo.id} → ${todo.status}`)
110
+ return { success: true, todo }
111
+ } catch (err) {
112
+ reply.code(400)
113
+ return { success: false, error: err.message }
114
+ }
115
+ })
116
+
117
+ // DELETE /api/todos/:id - Delete a todo
118
+ fastify.delete('/api/todos/:id', async (request, reply) => {
119
+ if (!verifyToken(request)) {
120
+ reply.code(401)
121
+ return { success: false, error: 'Unauthorized' }
122
+ }
123
+
124
+ const deleted = todoStore.remove(request.params.id)
125
+ if (!deleted) {
126
+ reply.code(404)
127
+ return { success: false, error: 'Todo not found' }
128
+ }
129
+
130
+ console.log(`[Todos] Deleted: ${request.params.id}`)
131
+ return { success: true }
132
+ })
133
+ }
134
+
135
+ module.exports = { todoRoutes }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Email Store (SQLite)
3
+ * Persists received emails to local SQLite database.
4
+ * Emails are received from Cloudflare Email Worker via HTTP POST.
5
+ *
6
+ * Attachments are stored separately with an approval gate:
7
+ * - approved=0: attachment metadata only, data accessible but not served to Claude Code
8
+ * - approved=1: attachment approved by human via HQ dashboard
9
+ */
10
+
11
+ const crypto = require('crypto')
12
+ const { getDb } = require('../db')
13
+
14
+ // Auto-prune read emails older than this
15
+ const PRUNE_DAYS = 90
16
+
17
+ /**
18
+ * Store a received email.
19
+ * @param {object} email - { from_address, to_address, subject?, body_text?, body_html?, labels?, attachments? }
20
+ * @returns {object} Created email record
21
+ */
22
+ function add(email) {
23
+ const db = getDb()
24
+
25
+ if (!email.from_address || !email.to_address) {
26
+ throw new Error('from_address and to_address are required')
27
+ }
28
+
29
+ const id = crypto.randomBytes(6).toString('hex')
30
+ const now = new Date().toISOString()
31
+
32
+ const record = {
33
+ id,
34
+ from_address: email.from_address,
35
+ to_address: email.to_address,
36
+ subject: email.subject || '',
37
+ body_text: email.body_text || '',
38
+ body_html: email.body_html || '',
39
+ received_at: email.received_at || now,
40
+ is_read: 0,
41
+ labels: JSON.stringify(email.labels || []),
42
+ }
43
+
44
+ const insertEmail = db.prepare(`
45
+ INSERT INTO emails (id, from_address, to_address, subject, body_text, body_html, received_at, is_read, labels)
46
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
47
+ `)
48
+
49
+ const insertAttachment = db.prepare(`
50
+ INSERT INTO email_attachments (id, email_id, filename, content_type, size_bytes, approved, data)
51
+ VALUES (?, ?, ?, ?, ?, 0, ?)
52
+ `)
53
+
54
+ const tx = db.transaction(() => {
55
+ insertEmail.run(
56
+ record.id,
57
+ record.from_address,
58
+ record.to_address,
59
+ record.subject,
60
+ record.body_text,
61
+ record.body_html,
62
+ record.received_at,
63
+ record.is_read,
64
+ record.labels
65
+ )
66
+
67
+ if (email.attachments && Array.isArray(email.attachments)) {
68
+ for (const att of email.attachments) {
69
+ const attId = crypto.randomBytes(6).toString('hex')
70
+ const data = att.data ? Buffer.from(att.data, 'base64') : null
71
+ insertAttachment.run(
72
+ attId,
73
+ record.id,
74
+ att.filename || 'unnamed',
75
+ att.content_type || 'application/octet-stream',
76
+ data ? data.length : 0,
77
+ data
78
+ )
79
+ }
80
+ }
81
+ })
82
+
83
+ tx()
84
+
85
+ console.log(`[EmailStore] Received: "${record.subject}" from ${record.from_address}`)
86
+
87
+ // Auto-prune old read emails
88
+ pruneOld()
89
+
90
+ return { ...record, labels: email.labels || [] }
91
+ }
92
+
93
+ /**
94
+ * Get a single email by ID (with attachment metadata, without attachment data).
95
+ * @param {string} id
96
+ * @returns {object|null}
97
+ */
98
+ function getById(id) {
99
+ const db = getDb()
100
+ const row = db.prepare('SELECT * FROM emails WHERE id = ?').get(id)
101
+ if (!row) return null
102
+
103
+ const attachments = db.prepare(
104
+ 'SELECT id, email_id, filename, content_type, size_bytes, approved FROM email_attachments WHERE email_id = ?'
105
+ ).all(id)
106
+
107
+ return { ...parseRow(row), attachments }
108
+ }
109
+
110
+ /**
111
+ * List emails with optional filters.
112
+ * @param {object} opts - { is_read?, from_address?, search?, limit? }
113
+ * @returns {Array}
114
+ */
115
+ function list(opts = {}) {
116
+ const db = getDb()
117
+ const conditions = []
118
+ const params = []
119
+
120
+ if (opts.is_read !== undefined && opts.is_read !== '') {
121
+ conditions.push('is_read = ?')
122
+ params.push(Number(opts.is_read))
123
+ }
124
+ if (opts.from_address) {
125
+ conditions.push('from_address LIKE ?')
126
+ params.push(`%${opts.from_address}%`)
127
+ }
128
+
129
+ // FTS5 search (trigram requires 3+ chars)
130
+ if (opts.search && opts.search.length >= 3) {
131
+ conditions.push('rowid IN (SELECT rowid FROM emails_fts WHERE emails_fts MATCH ?)')
132
+ params.push(`"${opts.search.replace(/"/g, '""')}"`)
133
+ } else if (opts.search && opts.search.length > 0) {
134
+ conditions.push('(subject LIKE ? OR body_text LIKE ?)')
135
+ params.push(`%${opts.search}%`, `%${opts.search}%`)
136
+ }
137
+
138
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
139
+ const limit = opts.limit || 50
140
+
141
+ const rows = db.prepare(`
142
+ SELECT * FROM emails ${where}
143
+ ORDER BY received_at DESC
144
+ LIMIT ?
145
+ `).all(...params, limit)
146
+
147
+ return rows.map(parseRow)
148
+ }
149
+
150
+ /**
151
+ * Mark an email as read.
152
+ * @param {string} id
153
+ * @returns {boolean}
154
+ */
155
+ function markAsRead(id) {
156
+ const db = getDb()
157
+ const result = db.prepare('UPDATE emails SET is_read = 1 WHERE id = ?').run(id)
158
+ return result.changes > 0
159
+ }
160
+
161
+ /**
162
+ * Mark an email as unread.
163
+ * @param {string} id
164
+ * @returns {boolean}
165
+ */
166
+ function markAsUnread(id) {
167
+ const db = getDb()
168
+ const result = db.prepare('UPDATE emails SET is_read = 0 WHERE id = ?').run(id)
169
+ return result.changes > 0
170
+ }
171
+
172
+ /**
173
+ * Delete an email and its attachments (CASCADE).
174
+ * @param {string} id
175
+ * @returns {boolean}
176
+ */
177
+ function remove(id) {
178
+ const db = getDb()
179
+ const result = db.prepare('DELETE FROM emails WHERE id = ?').run(id)
180
+ const deleted = result.changes > 0
181
+ if (deleted) console.log(`[EmailStore] Deleted: ${id}`)
182
+ return deleted
183
+ }
184
+
185
+ /**
186
+ * Get email summary (unread count, total count).
187
+ * @returns {{ unread: number, total: number, latest_at: string|null }}
188
+ */
189
+ function getSummary() {
190
+ const db = getDb()
191
+ const total = db.prepare('SELECT COUNT(*) as cnt FROM emails').get().cnt
192
+ const unread = db.prepare('SELECT COUNT(*) as cnt FROM emails WHERE is_read = 0').get().cnt
193
+ const latest = db.prepare('SELECT MAX(received_at) as latest FROM emails').get()
194
+
195
+ return { unread, total, latest_at: latest?.latest || null }
196
+ }
197
+
198
+ /**
199
+ * Approve an attachment (allow Claude Code to access it).
200
+ * @param {string} attachmentId
201
+ * @returns {boolean}
202
+ */
203
+ function approveAttachment(attachmentId) {
204
+ const db = getDb()
205
+ const result = db.prepare('UPDATE email_attachments SET approved = 1 WHERE id = ?').run(attachmentId)
206
+ if (result.changes > 0) {
207
+ console.log(`[EmailStore] Attachment approved: ${attachmentId}`)
208
+ }
209
+ return result.changes > 0
210
+ }
211
+
212
+ /**
213
+ * Get an attachment by ID (returns data only if approved).
214
+ * @param {string} attachmentId
215
+ * @param {boolean} requireApproval - If true, only return data for approved attachments
216
+ * @returns {object|null}
217
+ */
218
+ function getAttachment(attachmentId, requireApproval = true) {
219
+ const db = getDb()
220
+ const row = db.prepare('SELECT * FROM email_attachments WHERE id = ?').get(attachmentId)
221
+ if (!row) return null
222
+
223
+ if (requireApproval && !row.approved) {
224
+ // Return metadata only, strip data
225
+ return { id: row.id, email_id: row.email_id, filename: row.filename, content_type: row.content_type, size_bytes: row.size_bytes, approved: row.approved, data: null }
226
+ }
227
+
228
+ return row
229
+ }
230
+
231
+ /**
232
+ * Delete old read emails.
233
+ * @param {number} daysOld
234
+ */
235
+ function pruneOld(daysOld = PRUNE_DAYS) {
236
+ const db = getDb()
237
+ const result = db.prepare(`
238
+ DELETE FROM emails
239
+ WHERE is_read = 1
240
+ AND received_at < datetime('now', ?)
241
+ `).run(`-${daysOld} days`)
242
+
243
+ if (result.changes > 0) {
244
+ console.log(`[EmailStore] Pruned ${result.changes} read emails older than ${daysOld} days`)
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Parse a database row, deserializing JSON fields.
250
+ */
251
+ function parseRow(row) {
252
+ const parsed = { ...row }
253
+ if (parsed.labels) {
254
+ try {
255
+ parsed.labels = JSON.parse(parsed.labels)
256
+ } catch {
257
+ parsed.labels = []
258
+ }
259
+ }
260
+ return parsed
261
+ }
262
+
263
+ module.exports = {
264
+ add,
265
+ getById,
266
+ list,
267
+ markAsRead,
268
+ markAsUnread,
269
+ remove,
270
+ getSummary,
271
+ approveAttachment,
272
+ getAttachment,
273
+ pruneOld,
274
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Todo Store (SQLite)
3
+ * Persists TODO items to local SQLite database.
4
+ * Single source of truth for minion's task queue.
5
+ *
6
+ * TODOs are created from:
7
+ * - Thread conversations (LLM detects commitments)
8
+ * - HQ dashboard / API (user creates directly)
9
+ * - Minion self-generated (during workflows/chat)
10
+ */
11
+
12
+ const crypto = require('crypto')
13
+ const { getDb } = require('../db')
14
+
15
+ const VALID_STATUSES = ['pending', 'in_progress', 'done', 'cancelled']
16
+ const VALID_PRIORITIES = ['low', 'normal', 'high', 'urgent']
17
+ const VALID_SOURCE_TYPES = ['thread', 'workflow', 'directive', 'user', 'self']
18
+
19
+ // Auto-prune completed todos older than this
20
+ const PRUNE_DAYS = 30
21
+
22
+ /**
23
+ * Create a new TODO.
24
+ * @param {object} todo - { title, description?, priority?, source_type?, source_id?, project_id?, due_at?, data? }
25
+ * @returns {object} Created todo with generated ID
26
+ */
27
+ function add(todo) {
28
+ const db = getDb()
29
+
30
+ if (!todo.title) {
31
+ throw new Error('title is required')
32
+ }
33
+
34
+ const id = crypto.randomBytes(6).toString('hex')
35
+ const now = new Date().toISOString()
36
+ const status = todo.status && VALID_STATUSES.includes(todo.status) ? todo.status : 'pending'
37
+ const priority = todo.priority && VALID_PRIORITIES.includes(todo.priority) ? todo.priority : 'normal'
38
+ const source_type = todo.source_type && VALID_SOURCE_TYPES.includes(todo.source_type) ? todo.source_type : null
39
+
40
+ const record = {
41
+ id,
42
+ title: todo.title,
43
+ description: todo.description || null,
44
+ status,
45
+ priority,
46
+ source_type,
47
+ source_id: todo.source_id || null,
48
+ project_id: todo.project_id || null,
49
+ due_at: todo.due_at || null,
50
+ created_at: now,
51
+ updated_at: now,
52
+ completed_at: null,
53
+ ...(todo.data ? { data: todo.data } : {}),
54
+ }
55
+
56
+ db.prepare(`
57
+ INSERT INTO todos (id, title, description, status, priority, source_type, source_id, project_id, due_at, created_at, updated_at, completed_at, data)
58
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
59
+ `).run(
60
+ record.id,
61
+ record.title,
62
+ record.description,
63
+ record.status,
64
+ record.priority,
65
+ record.source_type,
66
+ record.source_id,
67
+ record.project_id,
68
+ record.due_at,
69
+ record.created_at,
70
+ record.updated_at,
71
+ record.completed_at,
72
+ record.data ? JSON.stringify(record.data) : null
73
+ )
74
+
75
+ console.log(`[TodoStore] Added: "${record.title}" (${record.priority}, source=${record.source_type || 'none'})`)
76
+
77
+ // Auto-prune old completed todos
78
+ pruneCompleted()
79
+
80
+ return record
81
+ }
82
+
83
+ /**
84
+ * Update an existing TODO.
85
+ * @param {string} id - Todo ID
86
+ * @param {object} updates - Fields to update
87
+ * @returns {object|null} Updated todo or null if not found
88
+ */
89
+ function update(id, updates) {
90
+ const db = getDb()
91
+ const existing = getById(id)
92
+ if (!existing) return null
93
+
94
+ const now = new Date().toISOString()
95
+
96
+ // Validate enum fields if provided
97
+ if (updates.status && !VALID_STATUSES.includes(updates.status)) {
98
+ throw new Error(`Invalid status: ${updates.status}`)
99
+ }
100
+ if (updates.priority && !VALID_PRIORITIES.includes(updates.priority)) {
101
+ throw new Error(`Invalid priority: ${updates.priority}`)
102
+ }
103
+
104
+ const merged = { ...existing, ...updates, updated_at: now }
105
+
106
+ // Set completed_at when transitioning to done/cancelled
107
+ if ((updates.status === 'done' || updates.status === 'cancelled') && !existing.completed_at) {
108
+ merged.completed_at = now
109
+ }
110
+ // Clear completed_at if moving back to active status
111
+ if ((updates.status === 'pending' || updates.status === 'in_progress') && existing.completed_at) {
112
+ merged.completed_at = null
113
+ }
114
+
115
+ db.prepare(`
116
+ UPDATE todos SET title = ?, description = ?, status = ?, priority = ?,
117
+ source_type = ?, source_id = ?, project_id = ?, due_at = ?,
118
+ updated_at = ?, completed_at = ?, data = ?
119
+ WHERE id = ?
120
+ `).run(
121
+ merged.title,
122
+ merged.description,
123
+ merged.status,
124
+ merged.priority,
125
+ merged.source_type,
126
+ merged.source_id,
127
+ merged.project_id,
128
+ merged.due_at,
129
+ merged.updated_at,
130
+ merged.completed_at,
131
+ merged.data ? JSON.stringify(merged.data) : null,
132
+ id
133
+ )
134
+
135
+ console.log(`[TodoStore] Updated: ${id} → ${merged.status}`)
136
+ return merged
137
+ }
138
+
139
+ /**
140
+ * Remove a TODO by ID.
141
+ * @param {string} id
142
+ * @returns {boolean} True if deleted
143
+ */
144
+ function remove(id) {
145
+ const db = getDb()
146
+ const result = db.prepare('DELETE FROM todos WHERE id = ?').run(id)
147
+ const deleted = result.changes > 0
148
+ if (deleted) console.log(`[TodoStore] Removed: ${id}`)
149
+ return deleted
150
+ }
151
+
152
+ /**
153
+ * Get a single TODO by ID.
154
+ * @param {string} id
155
+ * @returns {object|null}
156
+ */
157
+ function getById(id) {
158
+ const db = getDb()
159
+ const row = db.prepare('SELECT * FROM todos WHERE id = ?').get(id)
160
+ return row ? parseRow(row) : null
161
+ }
162
+
163
+ /**
164
+ * List TODOs with optional filters.
165
+ * @param {object} opts - { status?, priority?, project_id?, source_type?, limit? }
166
+ * @returns {Array}
167
+ */
168
+ function list(opts = {}) {
169
+ const db = getDb()
170
+ const conditions = []
171
+ const params = []
172
+
173
+ if (opts.status) {
174
+ conditions.push('status = ?')
175
+ params.push(opts.status)
176
+ }
177
+ if (opts.priority) {
178
+ conditions.push('priority = ?')
179
+ params.push(opts.priority)
180
+ }
181
+ if (opts.project_id) {
182
+ conditions.push('project_id = ?')
183
+ params.push(opts.project_id)
184
+ }
185
+ if (opts.source_type) {
186
+ conditions.push('source_type = ?')
187
+ params.push(opts.source_type)
188
+ }
189
+
190
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
191
+ const limit = opts.limit || 100
192
+
193
+ const rows = db.prepare(`
194
+ SELECT * FROM todos ${where}
195
+ ORDER BY
196
+ CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END,
197
+ created_at DESC
198
+ LIMIT ?
199
+ `).all(...params, limit)
200
+
201
+ return rows.map(parseRow)
202
+ }
203
+
204
+ /**
205
+ * Get summary counts by status.
206
+ * @returns {{ pending: number, in_progress: number, done: number, cancelled: number, updated_at: string|null }}
207
+ */
208
+ function getSummary() {
209
+ const db = getDb()
210
+ const rows = db.prepare('SELECT status, COUNT(*) as cnt FROM todos GROUP BY status').all()
211
+ const summary = { pending: 0, in_progress: 0, done: 0, cancelled: 0 }
212
+ for (const row of rows) {
213
+ if (summary.hasOwnProperty(row.status)) {
214
+ summary[row.status] = row.cnt
215
+ }
216
+ }
217
+
218
+ const latest = db.prepare('SELECT MAX(updated_at) as latest FROM todos').get()
219
+ summary.updated_at = latest?.latest || null
220
+
221
+ return summary
222
+ }
223
+
224
+ /**
225
+ * Delete completed/cancelled todos older than the specified number of days.
226
+ * @param {number} daysOld - Days after completion to prune (default 30)
227
+ */
228
+ function pruneCompleted(daysOld = PRUNE_DAYS) {
229
+ const db = getDb()
230
+ const result = db.prepare(`
231
+ DELETE FROM todos
232
+ WHERE status IN ('done', 'cancelled')
233
+ AND completed_at IS NOT NULL
234
+ AND completed_at < datetime('now', ?)
235
+ `).run(`-${daysOld} days`)
236
+
237
+ if (result.changes > 0) {
238
+ console.log(`[TodoStore] Pruned ${result.changes} completed todos older than ${daysOld} days`)
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Parse a database row, deserializing the data JSON field.
244
+ */
245
+ function parseRow(row) {
246
+ const parsed = { ...row }
247
+ if (parsed.data) {
248
+ try {
249
+ parsed.data = JSON.parse(parsed.data)
250
+ } catch {
251
+ // Keep as string if not valid JSON
252
+ }
253
+ }
254
+ return parsed
255
+ }
256
+
257
+ module.exports = {
258
+ add,
259
+ update,
260
+ remove,
261
+ getById,
262
+ list,
263
+ getSummary,
264
+ pruneCompleted,
265
+ }
package/linux/server.js CHANGED
@@ -20,6 +20,8 @@
20
20
  * Secrets: GET /api/secrets, PUT/DELETE /api/secrets/:key
21
21
  * Config: GET /api/config/backup, GET/PUT /api/config/env
22
22
  * Executions: GET /api/executions, GET /api/executions/:id, etc.
23
+ * TODOs: GET/POST /api/todos, GET/PUT/DELETE /api/todos/:id
24
+ * Emails: POST /api/email/inbox, GET /api/email/inbox, GET/PUT/DELETE /api/email/inbox/:id
23
25
  * Sudoers: GET /api/sudoers
24
26
  * ─────────────────────────────────────────────────────────────────────────────
25
27
  */
@@ -74,6 +76,8 @@ const { dailyLogRoutes } = require('../core/routes/daily-logs')
74
76
  const { sudoersRoutes } = require('../core/routes/sudoers')
75
77
  const { permissionRoutes } = require('../core/routes/permissions')
76
78
  const { threadRoutes } = require('../core/routes/threads')
79
+ const { todoRoutes } = require('../core/routes/todos')
80
+ const { emailRoutes } = require('../core/routes/emails')
77
81
  const { daemonRoutes } = require('../core/routes/daemons')
78
82
 
79
83
  // Linux-specific routes
@@ -281,6 +285,8 @@ async function registerAllRoutes(app) {
281
285
  await app.register(sudoersRoutes)
282
286
  await app.register(permissionRoutes)
283
287
  await app.register(threadRoutes)
288
+ await app.register(todoRoutes)
289
+ await app.register(emailRoutes)
284
290
  await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
285
291
 
286
292
  // Linux-specific routes
@@ -366,14 +372,16 @@ async function start() {
366
372
  // Send initial online heartbeat
367
373
  const { getStatus } = require('../core/routes/health')
368
374
  const { currentTask } = getStatus()
369
- sendHeartbeat({ status: 'online', current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), version }).catch(err => {
375
+ const todoStore = require('../core/stores/todo-store')
376
+ const getTodoSummary = () => { try { return todoStore.getSummary() } catch { return null } }
377
+ sendHeartbeat({ status: 'online', current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), todo_summary: getTodoSummary(), version }).catch(err => {
370
378
  console.error('[Heartbeat] Initial heartbeat failed:', err.message)
371
379
  })
372
380
 
373
381
  // Start periodic heartbeat
374
382
  heartbeatTimer = setInterval(() => {
375
383
  const { currentStatus, currentTask } = getStatus()
376
- sendHeartbeat({ status: currentStatus, current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), version }).then(() => {
384
+ sendHeartbeat({ status: currentStatus, current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), todo_summary: getTodoSummary(), version }).then(() => {
377
385
  lastBeatAt = new Date().toISOString()
378
386
  }).catch(err => {
379
387
  console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.6.4",
3
+ "version": "3.8.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -901,10 +901,11 @@ function Invoke-Setup {
901
901
  # Register cloudflared as NSSM service (config will be set by 'configure --setup-tunnel')
902
902
  Invoke-Nssm stop minion-cloudflared
903
903
  Invoke-Nssm remove minion-cloudflared confirm
904
- $cfConfigDir = Join-Path $TargetUserProfile '.cloudflared'
905
- $cfConfigPath = Join-Path $cfConfigDir 'config.yml'
906
904
  Invoke-Nssm install minion-cloudflared $cfExe
907
- Invoke-Nssm set minion-cloudflared AppParameters "tunnel run --config `"$cfConfigPath`""
905
+ # Use "tunnel run" without --config; cloudflared defaults to ~/.cloudflared/config.yml
906
+ # which resolves correctly via USERPROFILE env var set below.
907
+ # (NSSM mangles --flag to -flag, so we avoid passing double-dash flags entirely)
908
+ Invoke-Nssm set minion-cloudflared AppParameters "tunnel run"
908
909
  # Set USERPROFILE/HOME so cloudflared resolves ~/.cloudflared/ to the target user's profile
909
910
  # (NSSM runs as LocalSystem, whose ~ is C:\Windows\system32\config\systemprofile)
910
911
  $cfEnvLines = @(
@@ -1374,9 +1375,8 @@ function Invoke-Configure {
1374
1375
  if ($cfExe) {
1375
1376
  $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
1376
1377
  if ($isAdmin) {
1377
- $cfConfigPath = Join-Path $cfConfigDir 'config.yml'
1378
1378
  Invoke-Nssm install minion-cloudflared $cfExe
1379
- Invoke-Nssm set minion-cloudflared AppParameters "tunnel run --config `"$cfConfigPath`""
1379
+ Invoke-Nssm set minion-cloudflared AppParameters "tunnel run"
1380
1380
  # Set USERPROFILE/HOME so cloudflared resolves ~/.cloudflared/ to the target user's profile
1381
1381
  $cfEnvLines = @(
1382
1382
  "USERPROFILE=$TargetUserProfile",
package/win/server.js CHANGED
@@ -58,6 +58,8 @@ const { memoryRoutes } = require('../core/routes/memory')
58
58
  const { dailyLogRoutes } = require('../core/routes/daily-logs')
59
59
  const { permissionRoutes } = require('../core/routes/permissions')
60
60
  const { threadRoutes } = require('../core/routes/threads')
61
+ const { todoRoutes } = require('../core/routes/todos')
62
+ const { emailRoutes } = require('../core/routes/emails')
61
63
  const { daemonRoutes } = require('../core/routes/daemons')
62
64
 
63
65
  // Validate configuration
@@ -215,6 +217,8 @@ async function registerRoutes(app) {
215
217
  await app.register(dailyLogRoutes)
216
218
  await app.register(permissionRoutes)
217
219
  await app.register(threadRoutes)
220
+ await app.register(todoRoutes)
221
+ await app.register(emailRoutes)
218
222
  await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
219
223
 
220
224
  // Shutdown endpoint — allows detached restart/update scripts to trigger
@@ -311,14 +315,16 @@ async function start() {
311
315
  // Send initial online heartbeat
312
316
  const { getStatus } = require('../core/routes/health')
313
317
  const { currentTask } = getStatus()
314
- sendHeartbeat({ status: 'online', current_task: currentTask, config_warnings: getConfigWarnings(), version }).catch(err => {
318
+ const todoStore = require('../core/stores/todo-store')
319
+ const getTodoSummary = () => { try { return todoStore.getSummary() } catch { return null } }
320
+ sendHeartbeat({ status: 'online', current_task: currentTask, config_warnings: getConfigWarnings(), todo_summary: getTodoSummary(), version }).catch(err => {
315
321
  console.error('[Heartbeat] Initial heartbeat failed:', err.message)
316
322
  })
317
323
 
318
324
  // Start periodic heartbeat
319
325
  heartbeatTimer = setInterval(() => {
320
326
  const { currentStatus, currentTask } = getStatus()
321
- sendHeartbeat({ status: currentStatus, current_task: currentTask, config_warnings: getConfigWarnings(), version }).then(() => {
327
+ sendHeartbeat({ status: currentStatus, current_task: currentTask, config_warnings: getConfigWarnings(), todo_summary: getTodoSummary(), version }).then(() => {
322
328
  lastBeatAt = new Date().toISOString()
323
329
  }).catch(err => {
324
330
  console.error('[Heartbeat] Periodic heartbeat failed:', err.message)