@dalmasonto/taskflow-mcp 1.0.16 → 1.0.18
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/dist/config.d.ts +2 -0
- package/dist/config.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +119 -0
- package/package.json +2 -1
package/dist/config.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface TaskFlowConfig {
|
|
|
5
5
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
|
6
6
|
agentLivenessInterval: number;
|
|
7
7
|
maxPortAttempts: number;
|
|
8
|
+
relayUrl: string;
|
|
9
|
+
relayPushToken: string;
|
|
8
10
|
}
|
|
9
11
|
/** Keys that live in the config file (not SQLite) */
|
|
10
12
|
export declare const SERVER_CONFIG_KEYS: Set<string>;
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,8 @@ export const SERVER_CONFIG_KEYS = new Set([
|
|
|
9
9
|
'logLevel',
|
|
10
10
|
'agentLivenessInterval',
|
|
11
11
|
'maxPortAttempts',
|
|
12
|
+
'relayUrl',
|
|
13
|
+
'relayPushToken',
|
|
12
14
|
]);
|
|
13
15
|
// ─── defaults ────────────────────────────────────────────────────────
|
|
14
16
|
const DEFAULTS = {
|
|
@@ -18,6 +20,8 @@ const DEFAULTS = {
|
|
|
18
20
|
logLevel: 'info',
|
|
19
21
|
agentLivenessInterval: 30_000,
|
|
20
22
|
maxPortAttempts: 10,
|
|
23
|
+
relayUrl: '',
|
|
24
|
+
relayPushToken: '',
|
|
21
25
|
};
|
|
22
26
|
// ─── config file path ────────────────────────────────────────────────
|
|
23
27
|
const CONFIG_PATH = resolve(homedir(), '.taskflow_config.json');
|
|
@@ -57,6 +61,12 @@ function applyCliAndEnvOverrides(config) {
|
|
|
57
61
|
if (process.env.TASKFLOW_LOG_LEVEL) {
|
|
58
62
|
config.logLevel = process.env.TASKFLOW_LOG_LEVEL;
|
|
59
63
|
}
|
|
64
|
+
if (process.env.TASKFLOW_RELAY_URL) {
|
|
65
|
+
config.relayUrl = process.env.TASKFLOW_RELAY_URL;
|
|
66
|
+
}
|
|
67
|
+
if (process.env.TASKFLOW_RELAY_PUSH_TOKEN) {
|
|
68
|
+
config.relayPushToken = process.env.TASKFLOW_RELAY_PUSH_TOKEN;
|
|
69
|
+
}
|
|
60
70
|
// CLI args take highest priority
|
|
61
71
|
const portArgIdx = process.argv.indexOf('--port');
|
|
62
72
|
if (portArgIdx !== -1 && process.argv[portArgIdx + 1]) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import 'dotenv/config';
|
package/dist/index.js
CHANGED
package/dist/sse.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare function markSSEActive(): void;
|
|
|
3
3
|
/**
|
|
4
4
|
* Broadcast an SSE event. If this process owns the SSE server, send directly.
|
|
5
5
|
* Otherwise, relay via HTTP to the process that does.
|
|
6
|
+
* Also pushes to the remote relay server if configured.
|
|
6
7
|
*/
|
|
7
8
|
export declare function broadcast(event: string, data: object): void;
|
|
8
9
|
/** Returns the port the SSE server is actively using */
|
package/dist/sse.js
CHANGED
|
@@ -5,6 +5,8 @@ import { logActivity } from './helpers.js';
|
|
|
5
5
|
import { getConfig } from './config.js';
|
|
6
6
|
const SERVICE_ID = 'taskflow-mcp';
|
|
7
7
|
const PROBE_TIMEOUT_MS = 2000;
|
|
8
|
+
// ─── Relay upstream config (from config file, env vars, or .env) ────
|
|
9
|
+
const { relayUrl: RELAY_URL, relayPushToken: RELAY_PUSH_TOKEN } = getConfig();
|
|
8
10
|
const clients = new Set();
|
|
9
11
|
// ─── Event buffer for Last-Event-ID replay ──────────────────────────
|
|
10
12
|
const EVENT_BUFFER_SIZE = 200;
|
|
@@ -51,6 +53,86 @@ function readBody(req) {
|
|
|
51
53
|
req.on('end', () => resolve(body));
|
|
52
54
|
});
|
|
53
55
|
}
|
|
56
|
+
const PROMPT_PATTERNS = [
|
|
57
|
+
/Esc to cancel/i,
|
|
58
|
+
/Tab to amend/i,
|
|
59
|
+
/\(y\/n\)/i,
|
|
60
|
+
/\(yes\/no\)/i,
|
|
61
|
+
/Do you want to/i,
|
|
62
|
+
/Allow .+ for this/i,
|
|
63
|
+
];
|
|
64
|
+
function detectPrompt(content) {
|
|
65
|
+
const tail = content.split('\n').slice(-25).join('\n');
|
|
66
|
+
const hints = [];
|
|
67
|
+
if (/Esc to cancel/i.test(tail))
|
|
68
|
+
hints.push('Esc to cancel');
|
|
69
|
+
if (/Tab to amend/i.test(tail))
|
|
70
|
+
hints.push('Tab to amend');
|
|
71
|
+
if (/shift\+tab/i.test(tail))
|
|
72
|
+
hints.push('Shift+Tab');
|
|
73
|
+
const detected = PROMPT_PATTERNS.some(p => p.test(tail));
|
|
74
|
+
return { detected, hints };
|
|
75
|
+
}
|
|
76
|
+
/** Fast string hash (djb2) for content change detection */
|
|
77
|
+
function hashContent(str) {
|
|
78
|
+
let hash = 5381;
|
|
79
|
+
for (let i = 0; i < str.length; i++) {
|
|
80
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
|
81
|
+
}
|
|
82
|
+
return hash;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Unified terminal poller — single capture per agent, broadcasts:
|
|
86
|
+
* - terminal_capture: full pane content for UI rendering (only when changed)
|
|
87
|
+
* - agent_awaiting_input / agent_input_resolved: prompt state transitions
|
|
88
|
+
* - notification_created: when an agent first needs input
|
|
89
|
+
*/
|
|
90
|
+
function pollAgentTerminals(promptState, contentHashes) {
|
|
91
|
+
const db = getDb();
|
|
92
|
+
const agents = db.prepare("SELECT name, tmux_pane, status, pid FROM agent_registry WHERE status = 'connected' AND tmux_pane IS NOT NULL").all();
|
|
93
|
+
for (const agent of agents) {
|
|
94
|
+
try {
|
|
95
|
+
const content = execFileSync('tmux', ['capture-pane', '-p', '-S', '-', '-t', agent.tmux_pane], { timeout: 5000 }).toString();
|
|
96
|
+
// Only broadcast if content actually changed
|
|
97
|
+
const hash = hashContent(content);
|
|
98
|
+
const prevHash = contentHashes.get(agent.name);
|
|
99
|
+
if (hash !== prevHash) {
|
|
100
|
+
contentHashes.set(agent.name, hash);
|
|
101
|
+
broadcast('terminal_capture', {
|
|
102
|
+
entity: 'terminal',
|
|
103
|
+
action: 'terminal_capture',
|
|
104
|
+
payload: { name: agent.name, pane: agent.tmux_pane, content },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// Prompt detection with state transitions (always check, even if content unchanged hash-wise)
|
|
108
|
+
const { detected, hints } = detectPrompt(content);
|
|
109
|
+
const wasAwaiting = promptState.get(agent.name) ?? false;
|
|
110
|
+
if (detected && !wasAwaiting) {
|
|
111
|
+
promptState.set(agent.name, true);
|
|
112
|
+
const ts = new Date().toISOString();
|
|
113
|
+
const hintsText = hints.length > 0 ? ` (${hints.join(' · ')})` : '';
|
|
114
|
+
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);
|
|
115
|
+
const notification = db.prepare('SELECT * FROM notifications ORDER BY id DESC LIMIT 1').get();
|
|
116
|
+
broadcast('notification_created', { entity: 'notification', action: 'notification_created', payload: notification });
|
|
117
|
+
broadcast('agent_awaiting_input', { entity: 'agent', action: 'agent_awaiting_input', payload: { name: agent.name, hints, awaiting: true } });
|
|
118
|
+
}
|
|
119
|
+
else if (!detected && wasAwaiting) {
|
|
120
|
+
promptState.set(agent.name, false);
|
|
121
|
+
broadcast('agent_input_resolved', { entity: 'agent', action: 'agent_input_resolved', payload: { name: agent.name, awaiting: false } });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// tmux capture failed — agent pane may be gone
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Clean up agents that disconnected
|
|
129
|
+
for (const [name] of promptState) {
|
|
130
|
+
if (!agents.some(a => a.name === name)) {
|
|
131
|
+
promptState.delete(name);
|
|
132
|
+
contentHashes.delete(name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
54
136
|
export async function startSSEServer() {
|
|
55
137
|
const server = createServer(async (req, res) => {
|
|
56
138
|
// CORS headers for all requests
|
|
@@ -475,12 +557,37 @@ export async function startSSEServer() {
|
|
|
475
557
|
}
|
|
476
558
|
}
|
|
477
559
|
}, 30_000);
|
|
560
|
+
// Terminal capture & prompt detection — single capture loop for all agents
|
|
561
|
+
const promptState = new Map();
|
|
562
|
+
const contentHashes = new Map();
|
|
563
|
+
setInterval(() => {
|
|
564
|
+
try {
|
|
565
|
+
pollAgentTerminals(promptState, contentHashes);
|
|
566
|
+
}
|
|
567
|
+
catch { /* ignore */ }
|
|
568
|
+
}, 3_000);
|
|
478
569
|
if (port !== PREFERRED_PORT) {
|
|
479
570
|
console.log(`[SSE] listening on fallback port ${port} (preferred ${PREFERRED_PORT} was unavailable)`);
|
|
480
571
|
}
|
|
481
572
|
else {
|
|
482
573
|
console.log(`[SSE] listening on port ${port}`);
|
|
483
574
|
}
|
|
575
|
+
// Register with remote relay so it knows where to proxy commands
|
|
576
|
+
if (RELAY_URL && RELAY_PUSH_TOKEN) {
|
|
577
|
+
const localUrl = `http://${cfg.host}:${port}`;
|
|
578
|
+
fetch(`${RELAY_URL}/register`, {
|
|
579
|
+
method: 'POST',
|
|
580
|
+
headers: {
|
|
581
|
+
'Content-Type': 'application/json',
|
|
582
|
+
'Authorization': `Bearer ${RELAY_PUSH_TOKEN}`,
|
|
583
|
+
},
|
|
584
|
+
body: JSON.stringify({ url: localUrl }),
|
|
585
|
+
}).then(() => {
|
|
586
|
+
console.log(`[SSE] registered with relay at ${RELAY_URL}`);
|
|
587
|
+
}).catch((err) => {
|
|
588
|
+
console.error(`[SSE] relay registration failed: ${err.message}`);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
484
591
|
resolve();
|
|
485
592
|
});
|
|
486
593
|
}
|
|
@@ -504,6 +611,7 @@ export function markSSEActive() {
|
|
|
504
611
|
/**
|
|
505
612
|
* Broadcast an SSE event. If this process owns the SSE server, send directly.
|
|
506
613
|
* Otherwise, relay via HTTP to the process that does.
|
|
614
|
+
* Also pushes to the remote relay server if configured.
|
|
507
615
|
*/
|
|
508
616
|
export function broadcast(event, data) {
|
|
509
617
|
if (sseServerActive && clients.size > 0) {
|
|
@@ -520,6 +628,17 @@ export function broadcast(event, data) {
|
|
|
520
628
|
console.error(`[SSE] broadcast relay to port ${activePort} failed: ${err.message}`);
|
|
521
629
|
});
|
|
522
630
|
}
|
|
631
|
+
// Push to remote relay server (fire-and-forget)
|
|
632
|
+
if (RELAY_URL && RELAY_PUSH_TOKEN) {
|
|
633
|
+
fetch(`${RELAY_URL}/push`, {
|
|
634
|
+
method: 'POST',
|
|
635
|
+
headers: {
|
|
636
|
+
'Content-Type': 'application/json',
|
|
637
|
+
'Authorization': `Bearer ${RELAY_PUSH_TOKEN}`,
|
|
638
|
+
},
|
|
639
|
+
body: JSON.stringify({ event, data }),
|
|
640
|
+
}).catch(() => { }); // silent — relay may be down
|
|
641
|
+
}
|
|
523
642
|
}
|
|
524
643
|
/** Returns the port the SSE server is actively using */
|
|
525
644
|
export function getActivePort() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dalmasonto/taskflow-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
4
4
|
"description": "MCP server for TaskFlow — manage projects, tasks, timers, analytics via AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@modelcontextprotocol/sdk": "latest",
|
|
40
40
|
"better-sqlite3": "latest",
|
|
41
|
+
"dotenv": "^17.4.1",
|
|
41
42
|
"zod": "latest"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|