@dalmasonto/taskflow-mcp 1.0.35 → 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 (101) hide show
  1. package/README.md +138 -193
  2. package/dist/attachment-download.d.ts +74 -0
  3. package/dist/attachment-download.js +193 -0
  4. package/dist/attachment-download.js.map +1 -0
  5. package/dist/attachments.d.ts +23 -0
  6. package/dist/attachments.js +66 -0
  7. package/dist/attachments.js.map +1 -0
  8. package/dist/client.d.ts +206 -0
  9. package/dist/client.js +279 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +137 -18
  12. package/dist/config.js +187 -106
  13. package/dist/config.js.map +1 -0
  14. package/dist/connect.d.ts +89 -0
  15. package/dist/connect.js +269 -0
  16. package/dist/connect.js.map +1 -0
  17. package/dist/doctor.d.ts +24 -0
  18. package/dist/doctor.js +120 -0
  19. package/dist/doctor.js.map +1 -0
  20. package/dist/events.d.ts +186 -0
  21. package/dist/events.js +407 -0
  22. package/dist/events.js.map +1 -0
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.js +121 -218
  25. package/dist/index.js.map +1 -0
  26. package/dist/instructions.d.ts +12 -0
  27. package/dist/instructions.js +114 -0
  28. package/dist/instructions.js.map +1 -0
  29. package/dist/mint.d.ts +62 -0
  30. package/dist/mint.js +135 -0
  31. package/dist/mint.js.map +1 -0
  32. package/dist/mirror.d.ts +68 -0
  33. package/dist/mirror.js +103 -0
  34. package/dist/mirror.js.map +1 -0
  35. package/dist/pane-queue.d.ts +29 -0
  36. package/dist/pane-queue.js +35 -0
  37. package/dist/pane-queue.js.map +1 -0
  38. package/dist/prompts.d.ts +79 -0
  39. package/dist/prompts.js +211 -0
  40. package/dist/prompts.js.map +1 -0
  41. package/dist/resolve.d.ts +72 -0
  42. package/dist/resolve.js +89 -0
  43. package/dist/resolve.js.map +1 -0
  44. package/dist/runtime.d.ts +54 -0
  45. package/dist/runtime.js +321 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/dist/server.d.ts +56 -0
  48. package/dist/server.js +793 -0
  49. package/dist/server.js.map +1 -0
  50. package/dist/session-identifier.d.ts +48 -0
  51. package/dist/session-identifier.js +44 -0
  52. package/dist/session-identifier.js.map +1 -0
  53. package/dist/sessions-store.d.ts +38 -0
  54. package/dist/sessions-store.js +88 -0
  55. package/dist/sessions-store.js.map +1 -0
  56. package/dist/tmux.d.ts +200 -0
  57. package/dist/tmux.js +580 -0
  58. package/dist/tmux.js.map +1 -0
  59. package/hooks/metadata.mjs +99 -0
  60. package/hooks/permission-prompt.mjs +100 -0
  61. package/hooks/taskflow-hook.mjs +499 -0
  62. package/hooks/tool-logging.mjs +63 -0
  63. package/package.json +38 -29
  64. package/dist/agent-registry.d.ts +0 -28
  65. package/dist/agent-registry.js +0 -158
  66. package/dist/db.d.ts +0 -5
  67. package/dist/db.js +0 -220
  68. package/dist/helpers.d.ts +0 -21
  69. package/dist/helpers.js +0 -27
  70. package/dist/resources.d.ts +0 -2
  71. package/dist/resources.js +0 -89
  72. package/dist/retry.d.ts +0 -34
  73. package/dist/retry.js +0 -94
  74. package/dist/sse.d.ts +0 -10
  75. package/dist/sse.js +0 -824
  76. package/dist/tmux-bridge.d.ts +0 -13
  77. package/dist/tmux-bridge.js +0 -217
  78. package/dist/tools/activity.d.ts +0 -39
  79. package/dist/tools/activity.js +0 -152
  80. package/dist/tools/agent-inbox.d.ts +0 -12
  81. package/dist/tools/agent-inbox.js +0 -272
  82. package/dist/tools/agent.d.ts +0 -14
  83. package/dist/tools/agent.js +0 -168
  84. package/dist/tools/analytics.d.ts +0 -21
  85. package/dist/tools/analytics.js +0 -191
  86. package/dist/tools/checkpoint.d.ts +0 -27
  87. package/dist/tools/checkpoint.js +0 -105
  88. package/dist/tools/notifications.d.ts +0 -31
  89. package/dist/tools/notifications.js +0 -59
  90. package/dist/tools/projects.d.ts +0 -55
  91. package/dist/tools/projects.js +0 -112
  92. package/dist/tools/settings.d.ts +0 -19
  93. package/dist/tools/settings.js +0 -73
  94. package/dist/tools/tasks.d.ts +0 -105
  95. package/dist/tools/tasks.js +0 -403
  96. package/dist/tools/terminal.d.ts +0 -4
  97. package/dist/tools/terminal.js +0 -98
  98. package/dist/tools/timer.d.ts +0 -37
  99. package/dist/tools/timer.js +0 -154
  100. package/dist/types.d.ts +0 -83
  101. package/dist/types.js +0 -30
package/dist/sse.js DELETED
@@ -1,824 +0,0 @@
1
- import { createServer } from 'http';
2
- import { execFileSync } from 'child_process';
3
- import { getDb } from './db.js';
4
- import { logActivity } from './helpers.js';
5
- import { getConfig } from './config.js';
6
- import { validateKeys } from './tools/terminal.js';
7
- import { fetchWithRetry } from './retry.js';
8
- const SERVICE_ID = 'taskflow-mcp';
9
- const PROBE_TIMEOUT_MS = 2000;
10
- // ─── Relay upstream config (from config file, env vars, or .env) ────
11
- const { relayUrl: RELAY_URL, relayPushToken: RELAY_PUSH_TOKEN } = getConfig();
12
- const clients = new Set();
13
- // ─── Event buffer for Last-Event-ID replay ──────────────────────────
14
- const EVENT_BUFFER_SIZE = 200;
15
- let eventIdCounter = 0;
16
- const eventBuffer = [];
17
- function bufferEvent(event, data) {
18
- const id = ++eventIdCounter;
19
- eventBuffer.push({ id, event, data });
20
- if (eventBuffer.length > EVENT_BUFFER_SIZE) {
21
- eventBuffer.shift();
22
- }
23
- return id;
24
- }
25
- const cfg = getConfig();
26
- const PREFERRED_PORT = cfg.port;
27
- /** The port that is actually serving SSE — either ours or an existing instance's */
28
- let activePort = PREFERRED_PORT;
29
- /** Probe a port to check if a TaskFlow service is already running there */
30
- async function probeTaskFlow(port) {
31
- const controller = new AbortController();
32
- const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
33
- try {
34
- const res = await fetch(`http://localhost:${port}/healthz`, { signal: controller.signal });
35
- if (!res.ok)
36
- return false;
37
- const body = await res.json();
38
- return body.service === SERVICE_ID;
39
- }
40
- catch {
41
- return false;
42
- }
43
- finally {
44
- clearTimeout(timeout);
45
- }
46
- }
47
- function jsonResponse(res, status, data) {
48
- res.writeHead(status, { 'Content-Type': 'application/json' });
49
- res.end(JSON.stringify(data));
50
- }
51
- function readBody(req) {
52
- return new Promise((resolve, reject) => {
53
- let body = '';
54
- req.on('data', (chunk) => { body += chunk.toString(); });
55
- req.on('end', () => resolve(body));
56
- req.on('error', reject);
57
- });
58
- }
59
- /**
60
- * Detect an active Claude Code permission prompt.
61
- *
62
- * The definitive marker is "Esc to cancel" which only appears in the
63
- * footer of active prompts and disappears once the user responds.
64
- * We check only the last 10 lines of the VISIBLE screen (not full
65
- * scrollback) to avoid matching old, already-answered prompts.
66
- *
67
- * Generic patterns like "Do you want to" and "(y/n)" are intentionally
68
- * excluded — Claude writes these in prose responses too, causing false flags.
69
- */
70
- function detectPrompt(content) {
71
- // Only check last 10 lines — the prompt footer is always at the bottom
72
- const lines = content.split('\n');
73
- const tail = lines.slice(-10).join('\n');
74
- const hints = [];
75
- const hasEsc = /Esc to cancel/i.test(tail);
76
- const hasTab = /Tab to amend/i.test(tail);
77
- if (hasEsc)
78
- hints.push('Esc to cancel');
79
- if (hasTab)
80
- hints.push('Tab to amend');
81
- if (/shift\+tab/i.test(tail))
82
- hints.push('Shift+Tab');
83
- // Only flag as detected if we see the actual prompt footer
84
- const detected = hasEsc || hasTab;
85
- return { detected, hints };
86
- }
87
- /** Fast string hash (djb2) for content change detection */
88
- function hashContent(str) {
89
- let hash = 5381;
90
- for (let i = 0; i < str.length; i++) {
91
- hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
92
- }
93
- return hash;
94
- }
95
- /**
96
- * Unified terminal poller — single capture per agent, broadcasts:
97
- * - terminal_capture: full pane content for UI rendering (only when changed)
98
- * - agent_awaiting_input / agent_input_resolved: prompt state transitions
99
- * - notification_created: when an agent first needs input
100
- */
101
- function pollAgentTerminals(promptState, contentHashes) {
102
- const db = getDb();
103
- const agents = db.prepare("SELECT name, tmux_pane, status, pid FROM agent_registry WHERE status = 'connected' AND tmux_pane IS NOT NULL").all();
104
- for (const agent of agents) {
105
- try {
106
- const content = execFileSync('tmux', ['capture-pane', '-p', '-S', '-', '-t', agent.tmux_pane], { timeout: 5000 }).toString();
107
- // Only broadcast if content actually changed
108
- const hash = hashContent(content);
109
- const prevHash = contentHashes.get(agent.name);
110
- if (hash !== prevHash) {
111
- contentHashes.set(agent.name, hash);
112
- broadcast('terminal_capture', {
113
- entity: 'terminal',
114
- action: 'terminal_capture',
115
- payload: { name: agent.name, pane: agent.tmux_pane, content },
116
- });
117
- }
118
- // Prompt detection with state transitions (always check, even if content unchanged hash-wise)
119
- const { detected, hints } = detectPrompt(content);
120
- const wasAwaiting = promptState.get(agent.name) ?? false;
121
- if (detected && !wasAwaiting) {
122
- promptState.set(agent.name, true);
123
- const ts = new Date().toISOString();
124
- const hintsText = hints.length > 0 ? ` (${hints.join(' · ')})` : '';
125
- db.prepare('INSERT INTO notifications (title, message, type, created_at) VALUES (?, ?, ?, ?)').run(`Agent "${agent.name}" needs input`, `A permission prompt is waiting for your response${hintsText}`, 'warning', ts);
126
- const notification = db.prepare('SELECT * FROM notifications ORDER BY id DESC LIMIT 1').get();
127
- broadcast('notification_created', { entity: 'notification', action: 'notification_created', payload: notification });
128
- broadcast('agent_awaiting_input', { entity: 'agent', action: 'agent_awaiting_input', payload: { name: agent.name, hints, awaiting: true } });
129
- }
130
- else if (!detected && wasAwaiting) {
131
- promptState.set(agent.name, false);
132
- broadcast('agent_input_resolved', { entity: 'agent', action: 'agent_input_resolved', payload: { name: agent.name, awaiting: false } });
133
- }
134
- }
135
- catch {
136
- // tmux capture failed — agent pane may be gone
137
- }
138
- }
139
- // Clean up agents that disconnected
140
- for (const [name] of promptState) {
141
- if (!agents.some(a => a.name === name)) {
142
- promptState.delete(name);
143
- contentHashes.delete(name);
144
- }
145
- }
146
- }
147
- // ─── Relay command executor ─────────────────────────────────────────
148
- // Executes commands received from the relay's command queue locally.
149
- async function executeRelayCommand(type, payload) {
150
- const db = getDb();
151
- try {
152
- switch (type) {
153
- case 'send-keys': {
154
- const { agentName, keys, enter = true, literal = true } = payload;
155
- const { validateKeys } = await import('./tools/terminal.js');
156
- const violation = validateKeys(keys);
157
- if (violation)
158
- return { status: 'error', result: violation };
159
- const agent = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(agentName);
160
- if (!agent?.tmux_pane)
161
- return { status: 'error', result: `Agent "${agentName}" not found or has no pane` };
162
- if (agent.status !== 'connected')
163
- return { status: 'error', result: `Agent "${agentName}" is disconnected` };
164
- const pane = agent.tmux_pane;
165
- if (literal) {
166
- execFileSync('tmux', ['send-keys', '-t', pane, '-l', keys], { stdio: 'ignore', timeout: 5000 });
167
- if (enter)
168
- execFileSync('tmux', ['send-keys', '-t', pane, 'Enter'], { stdio: 'ignore', timeout: 5000 });
169
- }
170
- else {
171
- const args = ['send-keys', '-t', pane, keys];
172
- if (enter)
173
- args.push('Enter');
174
- execFileSync('tmux', args, { stdio: 'ignore', timeout: 5000 });
175
- }
176
- logActivity('terminal_send_keys', `[relay] Sent keys to ${agentName}: ${keys.slice(0, 50)}`, { entityType: 'agent' });
177
- return { status: 'done', result: { agent: agentName, keys, sent: true } };
178
- }
179
- case 'respond-message': {
180
- const { messageId, response } = payload;
181
- const ts = new Date().toISOString();
182
- const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(messageId);
183
- if (!msg)
184
- return { status: 'error', result: `Message ${messageId} not found` };
185
- if (msg.status !== 'pending')
186
- return { status: 'error', result: `Message ${messageId} is already ${msg.status}` };
187
- db.prepare('UPDATE agent_messages SET response = ?, status = ?, answered_at = ? WHERE id = ?')
188
- .run(response, 'answered', ts, messageId);
189
- const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(messageId);
190
- broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
191
- return { status: 'done', result: { id: messageId, status: 'answered' } };
192
- }
193
- case 'dismiss-message': {
194
- const { messageId } = payload;
195
- const ts = new Date().toISOString();
196
- db.prepare("UPDATE agent_messages SET status = 'dismissed', answered_at = ? WHERE id = ?").run(ts, payload.messageId);
197
- const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(messageId);
198
- broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
199
- return { status: 'done', result: { id: messageId, status: 'dismissed' } };
200
- }
201
- case 'send-to-agent': {
202
- const { recipient, message, projectId } = payload;
203
- const ts = new Date().toISOString();
204
- const result = db.prepare(`INSERT INTO agent_messages (project_id, question, sender_name, recipient_name, source, status, created_at)
205
- VALUES (?, ?, 'user', ?, 'ui', 'pending', ?)`).run(projectId ?? null, message, recipient, ts);
206
- const id = result.lastInsertRowid;
207
- const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
208
- broadcast('agent_question', { entity: 'agent_message', action: 'agent_question', payload: msg });
209
- return { status: 'done', result: { id, recipient } };
210
- }
211
- default:
212
- return { status: 'error', result: `Unknown command type: ${type}` };
213
- }
214
- }
215
- catch (err) {
216
- return { status: 'error', result: err.message };
217
- }
218
- }
219
- export async function startSSEServer() {
220
- const server = createServer(async (req, res) => {
221
- // CORS headers for all requests
222
- res.setHeader('Access-Control-Allow-Origin', '*');
223
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
224
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
225
- if (req.method === 'OPTIONS') {
226
- res.writeHead(204);
227
- res.end();
228
- return;
229
- }
230
- // GET /healthz — identity probe so other instances can detect us
231
- if (req.url === '/healthz' && req.method === 'GET') {
232
- jsonResponse(res, 200, { service: SERVICE_ID, pid: process.pid });
233
- return;
234
- }
235
- if (req.url === '/events' && req.method === 'GET') {
236
- res.writeHead(200, {
237
- 'Content-Type': 'text/event-stream',
238
- 'Cache-Control': 'no-cache',
239
- 'Connection': 'keep-alive',
240
- });
241
- // Send initial connection event
242
- res.write('event: connected\ndata: {}\n\n');
243
- // Replay missed events if client sends Last-Event-ID
244
- const lastEventId = req.headers['last-event-id'];
245
- if (lastEventId && typeof lastEventId === 'string') {
246
- const missed = eventBuffer.filter(e => e.id > Number(lastEventId));
247
- for (const e of missed) {
248
- res.write(`id: ${e.id}\nevent: ${e.event}\ndata: ${e.data}\n\n`);
249
- }
250
- }
251
- clients.add(res);
252
- req.on('close', () => {
253
- clients.delete(res);
254
- });
255
- return;
256
- }
257
- if (req.url === '/sync' && req.method === 'GET') {
258
- const db = getDb();
259
- const tasks = db.prepare('SELECT * FROM tasks').all();
260
- const projects = db.prepare('SELECT * FROM projects').all();
261
- const sessions = db.prepare('SELECT * FROM sessions').all();
262
- const settings = db.prepare('SELECT * FROM settings').all();
263
- const activityLogs = db.prepare('SELECT * FROM activity_logs ORDER BY created_at DESC LIMIT 200').all();
264
- const agentMessages = db.prepare('SELECT * FROM agent_messages ORDER BY created_at DESC LIMIT 100').all();
265
- const agentRegistry = db.prepare('SELECT * FROM agent_registry ORDER BY connected_at DESC').all();
266
- jsonResponse(res, 200, { tasks, projects, sessions, settings, activityLogs, agentMessages, agentRegistry });
267
- return;
268
- }
269
- // ─── Mutation endpoints ───────────────────────────────────────────
270
- if (req.url === '/api/clear-data' && req.method === 'POST') {
271
- const db = getDb();
272
- db.exec('DELETE FROM sessions');
273
- db.exec('DELETE FROM tasks');
274
- db.exec('DELETE FROM projects');
275
- db.exec('DELETE FROM notifications');
276
- db.exec('DELETE FROM activity_logs');
277
- db.exec('DELETE FROM agent_messages');
278
- db.exec('DELETE FROM agent_registry');
279
- logActivity('data_cleared', 'All data cleared via UI', { entityType: 'system' });
280
- broadcast('data_cleared', { entity: 'system', action: 'data_cleared', payload: {} });
281
- jsonResponse(res, 200, { cleared: true });
282
- return;
283
- }
284
- // PATCH /api/tasks/:id — partial update
285
- const taskPatchMatch = req.url?.match(/^\/api\/tasks\/(\d+)$/);
286
- if (taskPatchMatch && req.method === 'PATCH') {
287
- const db = getDb();
288
- const id = Number(taskPatchMatch[1]);
289
- const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
290
- if (!task) {
291
- jsonResponse(res, 404, { error: 'Task not found' });
292
- return;
293
- }
294
- let body;
295
- try {
296
- body = JSON.parse(await readBody(req));
297
- }
298
- catch {
299
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
300
- return;
301
- }
302
- const fieldMap = {
303
- title: 'title', description: 'description', status: 'status',
304
- priority: 'priority', projectId: 'project_id', dueDate: 'due_date',
305
- estimatedTime: 'estimated_time', dependencies: 'dependencies',
306
- tags: 'tags', links: 'links',
307
- };
308
- const sets = [];
309
- const vals = [];
310
- for (const [camel, col] of Object.entries(fieldMap)) {
311
- if (body[camel] !== undefined) {
312
- const val = body[camel];
313
- if (col === 'dependencies' || col === 'tags' || col === 'links') {
314
- sets.push(`${col} = ?`);
315
- vals.push(JSON.stringify(val));
316
- }
317
- else if (col === 'due_date' && val) {
318
- sets.push(`${col} = ?`);
319
- vals.push(new Date(val).toISOString());
320
- }
321
- else if (col === 'project_id' && (val === null || val === undefined)) {
322
- sets.push(`${col} = ?`);
323
- vals.push(null);
324
- }
325
- else {
326
- sets.push(`${col} = ?`);
327
- vals.push(val);
328
- }
329
- }
330
- }
331
- if (sets.length > 0) {
332
- sets.push('updated_at = ?');
333
- vals.push(new Date().toISOString());
334
- db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
335
- }
336
- const updated = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
337
- broadcast('task_updated', { entity: 'task', action: 'task_updated', payload: updated });
338
- jsonResponse(res, 200, updated);
339
- return;
340
- }
341
- // DELETE /api/tasks/:id
342
- const taskDeleteMatch = req.url?.match(/^\/api\/tasks\/(\d+)$/);
343
- if (taskDeleteMatch && req.method === 'DELETE') {
344
- const db = getDb();
345
- const id = Number(taskDeleteMatch[1]);
346
- const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
347
- if (!task) {
348
- jsonResponse(res, 404, { error: 'Task not found' });
349
- return;
350
- }
351
- db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
352
- logActivity('task_deleted', task.title, { entityType: 'task', entityId: id });
353
- broadcast('task_deleted', { entity: 'task', action: 'task_deleted', payload: { id } });
354
- jsonResponse(res, 200, { deleted: true, id });
355
- return;
356
- }
357
- // POST /api/tasks — create a task
358
- if (req.url === '/api/tasks' && req.method === 'POST') {
359
- const db = getDb();
360
- let body;
361
- try {
362
- body = JSON.parse(await readBody(req));
363
- }
364
- catch {
365
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
366
- return;
367
- }
368
- const ts = new Date().toISOString();
369
- const result = db.prepare(`INSERT INTO tasks (title, description, status, priority, project_id, dependencies, links, tags, due_date, estimated_time, created_at, updated_at)
370
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(body.title, body.description ?? null, body.status ?? 'not_started', body.priority ?? 'medium', body.projectId ?? null, JSON.stringify(body.dependencies ?? []), JSON.stringify(body.links ?? []), JSON.stringify(body.tags ?? []), body.dueDate ? new Date(body.dueDate).toISOString() : null, body.estimatedTime ?? null, ts, ts);
371
- const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid);
372
- logActivity('task_created', body.title, { entityType: 'task', entityId: result.lastInsertRowid });
373
- broadcast('task_created', { entity: 'task', action: 'task_created', payload: task });
374
- jsonResponse(res, 201, task);
375
- return;
376
- }
377
- // POST /api/sessions — create a timer session
378
- if (req.url === '/api/sessions' && req.method === 'POST') {
379
- const db = getDb();
380
- let body;
381
- try {
382
- body = JSON.parse(await readBody(req));
383
- }
384
- catch {
385
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
386
- return;
387
- }
388
- const ts = new Date().toISOString();
389
- const result = db.prepare('INSERT INTO sessions (task_id, start, end) VALUES (?, ?, ?)').run(body.taskId, body.start ? new Date(body.start).toISOString() : ts, body.end ? new Date(body.end).toISOString() : null);
390
- const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
391
- broadcast('timer_started', { entity: 'timer', action: 'timer_started', payload: { task_id: body.taskId, session } });
392
- jsonResponse(res, 201, session);
393
- return;
394
- }
395
- // PATCH /api/sessions/:id — update session (close it)
396
- const sessionPatchMatch = req.url?.match(/^\/api\/sessions\/(\d+)$/);
397
- if (sessionPatchMatch && req.method === 'PATCH') {
398
- const db = getDb();
399
- const id = Number(sessionPatchMatch[1]);
400
- let body;
401
- try {
402
- body = JSON.parse(await readBody(req));
403
- }
404
- catch {
405
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
406
- return;
407
- }
408
- if (body.end) {
409
- db.prepare('UPDATE sessions SET end = ? WHERE id = ?').run(new Date(body.end).toISOString(), id);
410
- }
411
- const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
412
- jsonResponse(res, 200, session);
413
- return;
414
- }
415
- // PATCH /api/projects/:id — partial update
416
- const projectPatchMatch = req.url?.match(/^\/api\/projects\/(\d+)$/);
417
- if (projectPatchMatch && req.method === 'PATCH') {
418
- const db = getDb();
419
- const id = Number(projectPatchMatch[1]);
420
- const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
421
- if (!project) {
422
- jsonResponse(res, 404, { error: 'Project not found' });
423
- return;
424
- }
425
- let body;
426
- try {
427
- body = JSON.parse(await readBody(req));
428
- }
429
- catch {
430
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
431
- return;
432
- }
433
- const fieldMap = {
434
- name: 'name', color: 'color', type: 'type', description: 'description',
435
- };
436
- const sets = [];
437
- const vals = [];
438
- for (const [camel, col] of Object.entries(fieldMap)) {
439
- if (body[camel] !== undefined) {
440
- sets.push(`${col} = ?`);
441
- vals.push(body[camel]);
442
- }
443
- }
444
- if (sets.length > 0) {
445
- sets.push('updated_at = ?');
446
- vals.push(new Date().toISOString());
447
- db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
448
- }
449
- const updated = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
450
- broadcast('project_updated', { entity: 'project', action: 'project_updated', payload: updated });
451
- jsonResponse(res, 200, updated);
452
- return;
453
- }
454
- // DELETE /api/projects/:id
455
- const projectDeleteMatch = req.url?.match(/^\/api\/projects\/(\d+)$/);
456
- if (projectDeleteMatch && req.method === 'DELETE') {
457
- const db = getDb();
458
- const id = Number(projectDeleteMatch[1]);
459
- const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
460
- if (!project) {
461
- jsonResponse(res, 404, { error: 'Project not found' });
462
- return;
463
- }
464
- db.prepare('DELETE FROM projects WHERE id = ?').run(id);
465
- logActivity('project_deleted', project.name, { entityType: 'project', entityId: id });
466
- broadcast('project_deleted', { entity: 'project', action: 'project_deleted', payload: { id } });
467
- jsonResponse(res, 200, { deleted: true, id });
468
- return;
469
- }
470
- // POST /api/broadcast — relay SSE events from other processes (e.g. MCP)
471
- if (req.url === '/api/broadcast' && req.method === 'POST') {
472
- let body;
473
- try {
474
- body = JSON.parse(await readBody(req));
475
- }
476
- catch {
477
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
478
- return;
479
- }
480
- if (body.event && body.data) {
481
- broadcastLocal(body.event, body.data);
482
- }
483
- jsonResponse(res, 200, { relayed: true });
484
- return;
485
- }
486
- // POST /api/agent-messages/:id/respond — user responds to an agent question
487
- const agentRespondMatch = req.url?.match(/^\/api\/agent-messages\/(\d+)\/respond$/);
488
- if (agentRespondMatch && req.method === 'POST') {
489
- const db = getDb();
490
- const id = Number(agentRespondMatch[1]);
491
- const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
492
- if (!message) {
493
- jsonResponse(res, 404, { error: 'Message not found' });
494
- return;
495
- }
496
- if (message.status === 'answered') {
497
- jsonResponse(res, 400, { error: 'Already answered' });
498
- return;
499
- }
500
- let body;
501
- try {
502
- body = JSON.parse(await readBody(req));
503
- }
504
- catch {
505
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
506
- return;
507
- }
508
- const response = body.response;
509
- if (!response) {
510
- jsonResponse(res, 400, { error: 'Response is required' });
511
- return;
512
- }
513
- const ts = new Date().toISOString();
514
- db.prepare('UPDATE agent_messages SET response = ?, status = ?, answered_at = ?, delivered = NULL WHERE id = ?')
515
- .run(response, 'answered', ts, id);
516
- const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
517
- broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
518
- logActivity('agent_question_answered', `Responded to: ${message.question}`, { entityType: 'agent_message', entityId: id });
519
- jsonResponse(res, 200, updated);
520
- return;
521
- }
522
- // POST /api/agent-messages/:id/dismiss — dismiss without responding (answered in terminal)
523
- const agentDismissMatch = req.url?.match(/^\/api\/agent-messages\/(\d+)\/dismiss$/);
524
- if (agentDismissMatch && req.method === 'POST') {
525
- const db = getDb();
526
- const id = Number(agentDismissMatch[1]);
527
- const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
528
- if (!message) {
529
- jsonResponse(res, 404, { error: 'Message not found' });
530
- return;
531
- }
532
- if (message.status !== 'pending') {
533
- jsonResponse(res, 400, { error: 'Message is not pending' });
534
- return;
535
- }
536
- const ts = new Date().toISOString();
537
- db.prepare('UPDATE agent_messages SET status = ?, answered_at = ? WHERE id = ?')
538
- .run('dismissed', ts, id);
539
- const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
540
- broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
541
- jsonResponse(res, 200, updated);
542
- return;
543
- }
544
- // POST /api/agent-messages/send — send a message (from user UI or capture system)
545
- if (req.url === '/api/agent-messages/send' && req.method === 'POST') {
546
- const db = getDb();
547
- let body;
548
- try {
549
- body = JSON.parse(await readBody(req));
550
- }
551
- catch {
552
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
553
- return;
554
- }
555
- const { recipient, message: msgText, projectId, source: msgSource, senderName } = body;
556
- if (!recipient || !msgText) {
557
- jsonResponse(res, 400, { error: 'recipient and message are required' });
558
- return;
559
- }
560
- const source = msgSource || 'ui';
561
- const sender = senderName || 'user';
562
- const ts = new Date().toISOString();
563
- const result = db.prepare(`INSERT INTO agent_messages (project_id, question, sender_name, recipient_name, source, status, created_at)
564
- VALUES (?, ?, ?, ?, ?, 'pending', ?)`).run(projectId ?? null, msgText, sender, recipient, source, ts);
565
- const id = result.lastInsertRowid;
566
- const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
567
- broadcast('agent_question', { entity: 'agent_message', action: 'agent_question', payload: msg });
568
- jsonResponse(res, 200, msg);
569
- return;
570
- }
571
- // GET /api/terminal/:agentName/capture — capture terminal content via tmux
572
- const captureMatch = req.url?.match(/^\/api\/terminal\/([^/]+)\/capture$/);
573
- if (captureMatch && req.method === 'GET') {
574
- const agentName = decodeURIComponent(captureMatch[1]);
575
- const db = getDb();
576
- const agent = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(agentName);
577
- if (!agent) {
578
- jsonResponse(res, 404, { error: `Agent "${agentName}" not found` });
579
- return;
580
- }
581
- if (!agent.tmux_pane) {
582
- jsonResponse(res, 400, { error: `Agent "${agentName}" has no tmux pane` });
583
- return;
584
- }
585
- try {
586
- const output = execFileSync('tmux', ['capture-pane', '-p', '-S', '-', '-t', agent.tmux_pane], { timeout: 5000 }).toString();
587
- jsonResponse(res, 200, { agent: agentName, pane: agent.tmux_pane, content: output });
588
- }
589
- catch (err) {
590
- jsonResponse(res, 500, { error: `Failed to capture pane: ${err.message}` });
591
- }
592
- return;
593
- }
594
- // POST /api/terminal/:agentName/send-keys — inject raw keystrokes into agent's tmux pane
595
- const sendKeysMatch = req.url?.match(/^\/api\/terminal\/([^/]+)\/send-keys$/);
596
- if (sendKeysMatch && req.method === 'POST') {
597
- const agentName = decodeURIComponent(sendKeysMatch[1]);
598
- const db = getDb();
599
- const agent = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(agentName);
600
- if (!agent) {
601
- jsonResponse(res, 404, { error: `Agent "${agentName}" not found` });
602
- return;
603
- }
604
- if (!agent.tmux_pane) {
605
- jsonResponse(res, 400, { error: `Agent "${agentName}" has no tmux pane` });
606
- return;
607
- }
608
- if (agent.status !== 'connected') {
609
- jsonResponse(res, 403, { error: `Agent "${agentName}" is disconnected — sending keys to a bare shell is blocked for security` });
610
- return;
611
- }
612
- let body;
613
- try {
614
- body = JSON.parse(await readBody(req));
615
- }
616
- catch {
617
- jsonResponse(res, 400, { error: 'Invalid JSON body' });
618
- return;
619
- }
620
- const keys = body.keys;
621
- const enter = body.enter !== false; // default: send Enter after keys
622
- const literal = body.literal !== false; // default: send as literal text
623
- if (typeof keys !== 'string') {
624
- jsonResponse(res, 400, { error: 'keys must be a string' });
625
- return;
626
- }
627
- // Block shell escape patterns and oversized payloads
628
- const violation = validateKeys(keys);
629
- if (violation) {
630
- jsonResponse(res, 403, { error: violation });
631
- return;
632
- }
633
- try {
634
- const pane = agent.tmux_pane;
635
- if (literal) {
636
- execFileSync('tmux', ['send-keys', '-t', pane, '-l', keys], { stdio: 'ignore', timeout: 5000 });
637
- if (enter)
638
- execFileSync('tmux', ['send-keys', '-t', pane, 'Enter'], { stdio: 'ignore', timeout: 5000 });
639
- }
640
- else {
641
- const args = ['send-keys', '-t', pane, keys];
642
- if (enter)
643
- args.push('Enter');
644
- execFileSync('tmux', args, { stdio: 'ignore', timeout: 5000 });
645
- }
646
- logActivity('terminal_send_keys', `Sent keys to ${agentName}: ${keys.slice(0, 50)}`, { entityType: 'agent' });
647
- jsonResponse(res, 200, { agent: agentName, pane: agent.tmux_pane, keys, enter, literal, sent: true });
648
- }
649
- catch (err) {
650
- jsonResponse(res, 500, { error: `Failed to send keys: ${err.message}` });
651
- }
652
- return;
653
- }
654
- // GET /api/terminal/agents — list agents with tmux panes for terminal interaction
655
- if (req.url === '/api/terminal/agents' && req.method === 'GET') {
656
- const db = getDb();
657
- const agents = db.prepare("SELECT name, tmux_pane, status, pid FROM agent_registry WHERE tmux_pane IS NOT NULL ORDER BY connected_at DESC").all();
658
- jsonResponse(res, 200, agents);
659
- return;
660
- }
661
- res.writeHead(404);
662
- res.end('Not Found');
663
- });
664
- // Try ports sequentially: probe occupied ports to see if they're ours
665
- await new Promise((resolve) => {
666
- let attempt = 0;
667
- function tryPort(port) {
668
- if (attempt >= cfg.maxPortAttempts) {
669
- console.error(`[SSE] failed to bind after ${cfg.maxPortAttempts} attempts (ports ${PREFERRED_PORT}–${port - 1})`);
670
- resolve();
671
- return;
672
- }
673
- server.once('error', async (err) => {
674
- if (err.code === 'EADDRINUSE') {
675
- const isTaskFlow = await probeTaskFlow(port);
676
- if (isTaskFlow) {
677
- // Another TaskFlow instance owns this port — connect to it instead
678
- activePort = port;
679
- console.error(`[SSE] port ${port} owned by another TaskFlow instance (pid probe) — using it`);
680
- resolve();
681
- }
682
- else {
683
- // Not ours — try next port
684
- console.error(`[SSE] port ${port} in use by non-TaskFlow service — trying ${port + 1}`);
685
- attempt++;
686
- tryPort(port + 1);
687
- }
688
- }
689
- else {
690
- console.error('[SSE] unexpected server error:', err.message);
691
- resolve();
692
- }
693
- });
694
- server.listen(port, cfg.host, () => {
695
- activePort = port;
696
- markSSEActive();
697
- // Heartbeat — keeps connections alive and detects stale clients
698
- setInterval(() => {
699
- const ping = `:ping ${Date.now()}\n\n`;
700
- for (const client of clients) {
701
- try {
702
- client.write(ping);
703
- }
704
- catch {
705
- clients.delete(client);
706
- }
707
- }
708
- }, 30_000);
709
- // Terminal capture & prompt detection — single capture loop for all agents
710
- const promptState = new Map();
711
- const contentHashes = new Map();
712
- setInterval(() => {
713
- try {
714
- pollAgentTerminals(promptState, contentHashes);
715
- }
716
- catch { /* ignore */ }
717
- }, 3_000);
718
- if (port !== PREFERRED_PORT) {
719
- console.error(`[SSE] listening on fallback port ${port} (preferred ${PREFERRED_PORT} was unavailable)`);
720
- }
721
- else {
722
- console.error(`[SSE] listening on port ${port}`);
723
- }
724
- // Relay command polling + state pushing (if relay is configured)
725
- if (RELAY_URL && RELAY_PUSH_TOKEN) {
726
- console.error(`[relay] command polling started → ${RELAY_URL}`);
727
- // Push lightweight sync state — only what the remote app needs
728
- // SSE events handle real-time updates after this initial load
729
- const pushState = () => {
730
- try {
731
- const db = getDb();
732
- const state = {
733
- agentRegistry: db.prepare('SELECT * FROM agent_registry').all(),
734
- agentMessages: db.prepare('SELECT * FROM agent_messages ORDER BY id DESC LIMIT 100').all(),
735
- notifications: db.prepare('SELECT * FROM notifications ORDER BY id DESC LIMIT 50').all(),
736
- projects: db.prepare('SELECT * FROM projects').all(),
737
- };
738
- fetch(`${RELAY_URL}/push/state`, {
739
- method: 'POST',
740
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_PUSH_TOKEN}` },
741
- body: JSON.stringify(state),
742
- }).catch(() => { });
743
- }
744
- catch { /* ignore */ }
745
- };
746
- pushState();
747
- setInterval(pushState, 60_000);
748
- // Poll for commands every 2s
749
- setInterval(async () => {
750
- try {
751
- const res = await fetchWithRetry('relay commands/pending', `${RELAY_URL}/commands/pending`, {
752
- headers: { 'Authorization': `Bearer ${RELAY_PUSH_TOKEN}` },
753
- });
754
- if (!res.ok)
755
- return;
756
- const commands = await res.json();
757
- for (const cmd of commands) {
758
- const result = await executeRelayCommand(cmd.type, cmd.payload);
759
- fetch(`${RELAY_URL}/commands/${cmd.id}/done`, {
760
- method: 'POST',
761
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_PUSH_TOKEN}` },
762
- body: JSON.stringify(result),
763
- }).catch(() => { });
764
- }
765
- }
766
- catch { /* relay may be down */ }
767
- }, 2_000);
768
- }
769
- resolve();
770
- });
771
- }
772
- tryPort(PREFERRED_PORT);
773
- });
774
- }
775
- /** Broadcast directly to connected SSE clients in this process */
776
- function broadcastLocal(event, data) {
777
- const dataStr = JSON.stringify(data);
778
- const id = bufferEvent(event, dataStr);
779
- const message = `id: ${id}\nevent: ${event}\ndata: ${dataStr}\n\n`;
780
- for (const client of clients) {
781
- client.write(message);
782
- }
783
- }
784
- /** Track whether this process owns the SSE server */
785
- let sseServerActive = false;
786
- export function markSSEActive() {
787
- sseServerActive = true;
788
- }
789
- /**
790
- * Broadcast an SSE event. If this process owns the SSE server, send directly.
791
- * Otherwise, relay via HTTP to the process that does.
792
- * Also pushes to the remote relay server if configured.
793
- */
794
- export function broadcast(event, data) {
795
- if (sseServerActive) {
796
- broadcastLocal(event, data);
797
- }
798
- else {
799
- // Relay to the SSE server owner via HTTP (with retry for transient failures)
800
- const body = JSON.stringify({ event, data });
801
- fetchWithRetry('broadcast relay', `http://localhost:${activePort}/api/broadcast`, {
802
- method: 'POST',
803
- headers: { 'Content-Type': 'application/json' },
804
- body,
805
- }).catch((err) => {
806
- console.error(`[SSE] broadcast relay to port ${activePort} failed: ${err.message}`);
807
- });
808
- }
809
- // Push to remote relay server (fire-and-forget)
810
- if (RELAY_URL && RELAY_PUSH_TOKEN) {
811
- fetch(`${RELAY_URL}/push`, {
812
- method: 'POST',
813
- headers: {
814
- 'Content-Type': 'application/json',
815
- 'Authorization': `Bearer ${RELAY_PUSH_TOKEN}`,
816
- },
817
- body: JSON.stringify({ event, data }),
818
- }).catch(() => { }); // silent — relay may be down
819
- }
820
- }
821
- /** Returns the port the SSE server is actively using */
822
- export function getActivePort() {
823
- return activePort;
824
- }