@dalmasonto/taskflow-mcp 1.0.35 → 2.1.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/README.md +138 -193
- package/dist/attachment-download.d.ts +74 -0
- package/dist/attachment-download.js +193 -0
- package/dist/attachment-download.js.map +1 -0
- package/dist/attachments.d.ts +23 -0
- package/dist/attachments.js +66 -0
- package/dist/attachments.js.map +1 -0
- package/dist/client.d.ts +206 -0
- package/dist/client.js +301 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +137 -18
- package/dist/config.js +187 -106
- package/dist/config.js.map +1 -0
- package/dist/connect.d.ts +89 -0
- package/dist/connect.js +269 -0
- package/dist/connect.js.map +1 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.js +120 -0
- package/dist/doctor.js.map +1 -0
- package/dist/events.d.ts +208 -0
- package/dist/events.js +454 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +121 -218
- package/dist/index.js.map +1 -0
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +114 -0
- package/dist/instructions.js.map +1 -0
- package/dist/mint.d.ts +62 -0
- package/dist/mint.js +135 -0
- package/dist/mint.js.map +1 -0
- package/dist/mirror.d.ts +68 -0
- package/dist/mirror.js +103 -0
- package/dist/mirror.js.map +1 -0
- package/dist/pane-queue.d.ts +29 -0
- package/dist/pane-queue.js +35 -0
- package/dist/pane-queue.js.map +1 -0
- package/dist/prompts.d.ts +79 -0
- package/dist/prompts.js +211 -0
- package/dist/prompts.js.map +1 -0
- package/dist/resolve.d.ts +72 -0
- package/dist/resolve.js +89 -0
- package/dist/resolve.js.map +1 -0
- package/dist/runtime.d.ts +54 -0
- package/dist/runtime.js +339 -0
- package/dist/runtime.js.map +1 -0
- package/dist/server.d.ts +56 -0
- package/dist/server.js +793 -0
- package/dist/server.js.map +1 -0
- package/dist/session-identifier.d.ts +48 -0
- package/dist/session-identifier.js +44 -0
- package/dist/session-identifier.js.map +1 -0
- package/dist/sessions-store.d.ts +38 -0
- package/dist/sessions-store.js +88 -0
- package/dist/sessions-store.js.map +1 -0
- package/dist/tmux.d.ts +200 -0
- package/dist/tmux.js +580 -0
- package/dist/tmux.js.map +1 -0
- package/hooks/metadata.mjs +99 -0
- package/hooks/permission-prompt.mjs +100 -0
- package/hooks/taskflow-hook.mjs +499 -0
- package/hooks/tool-logging.mjs +63 -0
- package/package.json +38 -29
- package/dist/agent-registry.d.ts +0 -28
- package/dist/agent-registry.js +0 -158
- package/dist/db.d.ts +0 -5
- package/dist/db.js +0 -220
- package/dist/helpers.d.ts +0 -21
- package/dist/helpers.js +0 -27
- package/dist/resources.d.ts +0 -2
- package/dist/resources.js +0 -89
- package/dist/retry.d.ts +0 -34
- package/dist/retry.js +0 -94
- package/dist/sse.d.ts +0 -10
- package/dist/sse.js +0 -824
- package/dist/tmux-bridge.d.ts +0 -13
- package/dist/tmux-bridge.js +0 -217
- package/dist/tools/activity.d.ts +0 -39
- package/dist/tools/activity.js +0 -152
- package/dist/tools/agent-inbox.d.ts +0 -12
- package/dist/tools/agent-inbox.js +0 -272
- package/dist/tools/agent.d.ts +0 -14
- package/dist/tools/agent.js +0 -168
- package/dist/tools/analytics.d.ts +0 -21
- package/dist/tools/analytics.js +0 -191
- package/dist/tools/checkpoint.d.ts +0 -27
- package/dist/tools/checkpoint.js +0 -105
- package/dist/tools/notifications.d.ts +0 -31
- package/dist/tools/notifications.js +0 -59
- package/dist/tools/projects.d.ts +0 -55
- package/dist/tools/projects.js +0 -112
- package/dist/tools/settings.d.ts +0 -19
- package/dist/tools/settings.js +0 -73
- package/dist/tools/tasks.d.ts +0 -105
- package/dist/tools/tasks.js +0 -403
- package/dist/tools/terminal.d.ts +0 -4
- package/dist/tools/terminal.js +0 -98
- package/dist/tools/timer.d.ts +0 -37
- package/dist/tools/timer.js +0 -154
- package/dist/types.d.ts +0 -83
- 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
|
-
}
|