@dalmasonto/taskflow-mcp 1.0.17 → 1.0.19
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/agent-registry.js +6 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +18 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -1
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +106 -25
- package/dist/tools/agent-inbox.d.ts +5 -0
- package/dist/tools/agent-inbox.js +20 -3
- package/dist/tools/terminal.d.ts +2 -0
- package/dist/tools/terminal.js +27 -2
- package/package.json +2 -1
package/dist/agent-registry.js
CHANGED
|
@@ -57,8 +57,12 @@ export function registerAgent(options) {
|
|
|
57
57
|
checkAgentLiveness();
|
|
58
58
|
// Find all entries for this project path
|
|
59
59
|
const entries = db.prepare('SELECT * FROM agent_registry WHERE project_path = ? ORDER BY connected_at DESC').all(projectPath);
|
|
60
|
-
// Priority 1: Reuse a disconnected entry (
|
|
61
|
-
|
|
60
|
+
// Priority 1: Reuse a disconnected entry that had the SAME PID (exact session resume)
|
|
61
|
+
// Priority 2: Reuse any disconnected entry for this path (new session, same project)
|
|
62
|
+
// This prevents a reconnecting agent from stealing another agent's identity
|
|
63
|
+
// when two sessions share the same project directory.
|
|
64
|
+
const disconnected = entries.find(e => e.status === 'disconnected' && e.pid === agentPid)
|
|
65
|
+
|| entries.find(e => e.status === 'disconnected');
|
|
62
66
|
if (disconnected) {
|
|
63
67
|
const newName = options?.customName || disconnected.name;
|
|
64
68
|
const renamed = newName !== disconnected.name;
|
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,9 +20,18 @@ 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
|
+
// Config lives alongside the database in ~/.taskflow/
|
|
28
|
+
// Falls back to legacy ~/.taskflow_config.json for backward compat
|
|
29
|
+
import { existsSync } from 'fs';
|
|
30
|
+
const NEW_CONFIG_PATH = resolve(homedir(), '.taskflow', 'config.json');
|
|
31
|
+
const LEGACY_CONFIG_PATH = resolve(homedir(), '.taskflow_config.json');
|
|
32
|
+
const CONFIG_PATH = existsSync(NEW_CONFIG_PATH) ? NEW_CONFIG_PATH
|
|
33
|
+
: existsSync(LEGACY_CONFIG_PATH) ? LEGACY_CONFIG_PATH
|
|
34
|
+
: NEW_CONFIG_PATH; // default to new path for fresh installs
|
|
24
35
|
// ─── loader ──────────────────────────────────────────────────────────
|
|
25
36
|
function loadFromFile() {
|
|
26
37
|
try {
|
|
@@ -57,6 +68,12 @@ function applyCliAndEnvOverrides(config) {
|
|
|
57
68
|
if (process.env.TASKFLOW_LOG_LEVEL) {
|
|
58
69
|
config.logLevel = process.env.TASKFLOW_LOG_LEVEL;
|
|
59
70
|
}
|
|
71
|
+
if (process.env.TASKFLOW_RELAY_URL) {
|
|
72
|
+
config.relayUrl = process.env.TASKFLOW_RELAY_URL;
|
|
73
|
+
}
|
|
74
|
+
if (process.env.TASKFLOW_RELAY_PUSH_TOKEN) {
|
|
75
|
+
config.relayPushToken = process.env.TASKFLOW_RELAY_PUSH_TOKEN;
|
|
76
|
+
}
|
|
60
77
|
// CLI args take highest priority
|
|
61
78
|
const portArgIdx = process.argv.indexOf('--port');
|
|
62
79
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
2
3
|
import { getConfig } from './config.js';
|
|
3
4
|
import { startSSEServer } from './sse.js';
|
|
4
5
|
import { getDb } from './db.js';
|
|
@@ -139,8 +140,10 @@ if (!httpOnly) {
|
|
|
139
140
|
const transport = new StdioServerTransport();
|
|
140
141
|
await server.connect(transport);
|
|
141
142
|
const { registerAgent, unregisterAgent } = await import('./agent-registry.js');
|
|
142
|
-
|
|
143
|
+
const { setAgentName } = await import('./tools/agent-inbox.js');
|
|
144
|
+
// Auto-register this agent and sync name to agent-inbox tools
|
|
143
145
|
const agentName = registerAgent();
|
|
146
|
+
setAgentName(agentName);
|
|
144
147
|
const agentPid = process.ppid;
|
|
145
148
|
console.error(`[agent] registered as "${agentName}"`);
|
|
146
149
|
let cleanup = () => { try {
|
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
|
@@ -3,8 +3,11 @@ import { execFileSync } from 'child_process';
|
|
|
3
3
|
import { getDb } from './db.js';
|
|
4
4
|
import { logActivity } from './helpers.js';
|
|
5
5
|
import { getConfig } from './config.js';
|
|
6
|
+
import { validateKeys } from './tools/terminal.js';
|
|
6
7
|
const SERVICE_ID = 'taskflow-mcp';
|
|
7
8
|
const PROBE_TIMEOUT_MS = 2000;
|
|
9
|
+
// ─── Relay upstream config (from config file, env vars, or .env) ────
|
|
10
|
+
const { relayUrl: RELAY_URL, relayPushToken: RELAY_PUSH_TOKEN } = getConfig();
|
|
8
11
|
const clients = new Set();
|
|
9
12
|
// ─── Event buffer for Last-Event-ID replay ──────────────────────────
|
|
10
13
|
const EVENT_BUFFER_SIZE = 200;
|
|
@@ -51,37 +54,70 @@ function readBody(req) {
|
|
|
51
54
|
req.on('end', () => resolve(body));
|
|
52
55
|
});
|
|
53
56
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Detect an active Claude Code permission prompt.
|
|
59
|
+
*
|
|
60
|
+
* The definitive marker is "Esc to cancel" which only appears in the
|
|
61
|
+
* footer of active prompts and disappears once the user responds.
|
|
62
|
+
* We check only the last 10 lines of the VISIBLE screen (not full
|
|
63
|
+
* scrollback) to avoid matching old, already-answered prompts.
|
|
64
|
+
*
|
|
65
|
+
* Generic patterns like "Do you want to" and "(y/n)" are intentionally
|
|
66
|
+
* excluded — Claude writes these in prose responses too, causing false flags.
|
|
67
|
+
*/
|
|
62
68
|
function detectPrompt(content) {
|
|
63
|
-
|
|
69
|
+
// Only check last 10 lines — the prompt footer is always at the bottom
|
|
70
|
+
const lines = content.split('\n');
|
|
71
|
+
const tail = lines.slice(-10).join('\n');
|
|
64
72
|
const hints = [];
|
|
65
|
-
|
|
73
|
+
const hasEsc = /Esc to cancel/i.test(tail);
|
|
74
|
+
const hasTab = /Tab to amend/i.test(tail);
|
|
75
|
+
if (hasEsc)
|
|
66
76
|
hints.push('Esc to cancel');
|
|
67
|
-
if (
|
|
77
|
+
if (hasTab)
|
|
68
78
|
hints.push('Tab to amend');
|
|
69
79
|
if (/shift\+tab/i.test(tail))
|
|
70
80
|
hints.push('Shift+Tab');
|
|
71
|
-
|
|
81
|
+
// Only flag as detected if we see the actual prompt footer
|
|
82
|
+
const detected = hasEsc || hasTab;
|
|
72
83
|
return { detected, hints };
|
|
73
84
|
}
|
|
74
|
-
|
|
85
|
+
/** Fast string hash (djb2) for content change detection */
|
|
86
|
+
function hashContent(str) {
|
|
87
|
+
let hash = 5381;
|
|
88
|
+
for (let i = 0; i < str.length; i++) {
|
|
89
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
|
90
|
+
}
|
|
91
|
+
return hash;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Unified terminal poller — single capture per agent, broadcasts:
|
|
95
|
+
* - terminal_capture: full pane content for UI rendering (only when changed)
|
|
96
|
+
* - agent_awaiting_input / agent_input_resolved: prompt state transitions
|
|
97
|
+
* - notification_created: when an agent first needs input
|
|
98
|
+
*/
|
|
99
|
+
function pollAgentTerminals(promptState, contentHashes) {
|
|
75
100
|
const db = getDb();
|
|
76
101
|
const agents = db.prepare("SELECT name, tmux_pane, status, pid FROM agent_registry WHERE status = 'connected' AND tmux_pane IS NOT NULL").all();
|
|
77
102
|
for (const agent of agents) {
|
|
78
103
|
try {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const
|
|
104
|
+
const content = execFileSync('tmux', ['capture-pane', '-p', '-S', '-', '-t', agent.tmux_pane], { timeout: 5000 }).toString();
|
|
105
|
+
// Only broadcast if content actually changed
|
|
106
|
+
const hash = hashContent(content);
|
|
107
|
+
const prevHash = contentHashes.get(agent.name);
|
|
108
|
+
if (hash !== prevHash) {
|
|
109
|
+
contentHashes.set(agent.name, hash);
|
|
110
|
+
broadcast('terminal_capture', {
|
|
111
|
+
entity: 'terminal',
|
|
112
|
+
action: 'terminal_capture',
|
|
113
|
+
payload: { name: agent.name, pane: agent.tmux_pane, content },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// Prompt detection with state transitions (always check, even if content unchanged hash-wise)
|
|
117
|
+
const { detected, hints } = detectPrompt(content);
|
|
118
|
+
const wasAwaiting = promptState.get(agent.name) ?? false;
|
|
82
119
|
if (detected && !wasAwaiting) {
|
|
83
|
-
|
|
84
|
-
prevState.set(agent.name, true);
|
|
120
|
+
promptState.set(agent.name, true);
|
|
85
121
|
const ts = new Date().toISOString();
|
|
86
122
|
const hintsText = hints.length > 0 ? ` (${hints.join(' · ')})` : '';
|
|
87
123
|
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);
|
|
@@ -90,8 +126,7 @@ function pollAgentPrompts(prevState) {
|
|
|
90
126
|
broadcast('agent_awaiting_input', { entity: 'agent', action: 'agent_awaiting_input', payload: { name: agent.name, hints, awaiting: true } });
|
|
91
127
|
}
|
|
92
128
|
else if (!detected && wasAwaiting) {
|
|
93
|
-
|
|
94
|
-
prevState.set(agent.name, false);
|
|
129
|
+
promptState.set(agent.name, false);
|
|
95
130
|
broadcast('agent_input_resolved', { entity: 'agent', action: 'agent_input_resolved', payload: { name: agent.name, awaiting: false } });
|
|
96
131
|
}
|
|
97
132
|
}
|
|
@@ -100,9 +135,10 @@ function pollAgentPrompts(prevState) {
|
|
|
100
135
|
}
|
|
101
136
|
}
|
|
102
137
|
// Clean up agents that disconnected
|
|
103
|
-
for (const [name] of
|
|
138
|
+
for (const [name] of promptState) {
|
|
104
139
|
if (!agents.some(a => a.name === name)) {
|
|
105
|
-
|
|
140
|
+
promptState.delete(name);
|
|
141
|
+
contentHashes.delete(name);
|
|
106
142
|
}
|
|
107
143
|
}
|
|
108
144
|
}
|
|
@@ -439,6 +475,10 @@ export async function startSSEServer() {
|
|
|
439
475
|
jsonResponse(res, 400, { error: `Agent "${agentName}" has no tmux pane` });
|
|
440
476
|
return;
|
|
441
477
|
}
|
|
478
|
+
if (agent.status !== 'connected') {
|
|
479
|
+
jsonResponse(res, 403, { error: `Agent "${agentName}" is disconnected — sending keys to a bare shell is blocked for security` });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
442
482
|
let body;
|
|
443
483
|
try {
|
|
444
484
|
body = JSON.parse(await readBody(req));
|
|
@@ -454,6 +494,12 @@ export async function startSSEServer() {
|
|
|
454
494
|
jsonResponse(res, 400, { error: 'keys must be a string' });
|
|
455
495
|
return;
|
|
456
496
|
}
|
|
497
|
+
// Block shell escape patterns and oversized payloads
|
|
498
|
+
const violation = validateKeys(keys);
|
|
499
|
+
if (violation) {
|
|
500
|
+
jsonResponse(res, 403, { error: violation });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
457
503
|
try {
|
|
458
504
|
const pane = agent.tmux_pane;
|
|
459
505
|
if (literal) {
|
|
@@ -530,20 +576,43 @@ export async function startSSEServer() {
|
|
|
530
576
|
}
|
|
531
577
|
}
|
|
532
578
|
}, 30_000);
|
|
533
|
-
//
|
|
579
|
+
// Terminal capture & prompt detection — single capture loop for all agents
|
|
534
580
|
const promptState = new Map();
|
|
581
|
+
const contentHashes = new Map();
|
|
535
582
|
setInterval(() => {
|
|
536
583
|
try {
|
|
537
|
-
|
|
584
|
+
pollAgentTerminals(promptState, contentHashes);
|
|
538
585
|
}
|
|
539
586
|
catch { /* ignore */ }
|
|
540
|
-
},
|
|
587
|
+
}, 3_000);
|
|
541
588
|
if (port !== PREFERRED_PORT) {
|
|
542
589
|
console.log(`[SSE] listening on fallback port ${port} (preferred ${PREFERRED_PORT} was unavailable)`);
|
|
543
590
|
}
|
|
544
591
|
else {
|
|
545
592
|
console.log(`[SSE] listening on port ${port}`);
|
|
546
593
|
}
|
|
594
|
+
// Register with remote relay so it knows where to proxy commands
|
|
595
|
+
if (RELAY_URL && RELAY_PUSH_TOKEN) {
|
|
596
|
+
const localUrl = `http://${cfg.host}:${port}`;
|
|
597
|
+
fetch(`${RELAY_URL}/register`, {
|
|
598
|
+
method: 'POST',
|
|
599
|
+
headers: {
|
|
600
|
+
'Content-Type': 'application/json',
|
|
601
|
+
'Authorization': `Bearer ${RELAY_PUSH_TOKEN}`,
|
|
602
|
+
},
|
|
603
|
+
body: JSON.stringify({ url: localUrl }),
|
|
604
|
+
}).then(async (res) => {
|
|
605
|
+
if (res.ok) {
|
|
606
|
+
console.log(`[SSE] registered with relay at ${RELAY_URL}`);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
const body = await res.text().catch(() => '');
|
|
610
|
+
console.error(`[SSE] relay registration rejected: ${res.status} ${body}`);
|
|
611
|
+
}
|
|
612
|
+
}).catch((err) => {
|
|
613
|
+
console.error(`[SSE] relay registration failed: ${err.message}`);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
547
616
|
resolve();
|
|
548
617
|
});
|
|
549
618
|
}
|
|
@@ -567,6 +636,7 @@ export function markSSEActive() {
|
|
|
567
636
|
/**
|
|
568
637
|
* Broadcast an SSE event. If this process owns the SSE server, send directly.
|
|
569
638
|
* Otherwise, relay via HTTP to the process that does.
|
|
639
|
+
* Also pushes to the remote relay server if configured.
|
|
570
640
|
*/
|
|
571
641
|
export function broadcast(event, data) {
|
|
572
642
|
if (sseServerActive && clients.size > 0) {
|
|
@@ -583,6 +653,17 @@ export function broadcast(event, data) {
|
|
|
583
653
|
console.error(`[SSE] broadcast relay to port ${activePort} failed: ${err.message}`);
|
|
584
654
|
});
|
|
585
655
|
}
|
|
656
|
+
// Push to remote relay server (fire-and-forget)
|
|
657
|
+
if (RELAY_URL && RELAY_PUSH_TOKEN) {
|
|
658
|
+
fetch(`${RELAY_URL}/push`, {
|
|
659
|
+
method: 'POST',
|
|
660
|
+
headers: {
|
|
661
|
+
'Content-Type': 'application/json',
|
|
662
|
+
'Authorization': `Bearer ${RELAY_PUSH_TOKEN}`,
|
|
663
|
+
},
|
|
664
|
+
body: JSON.stringify({ event, data }),
|
|
665
|
+
}).catch(() => { }); // silent — relay may be down
|
|
666
|
+
}
|
|
586
667
|
}
|
|
587
668
|
/** Returns the port the SSE server is actively using */
|
|
588
669
|
export function getActivePort() {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
/** The name assigned to this agent after registration */
|
|
3
3
|
declare let myAgentName: string | null;
|
|
4
|
+
/** Set the agent name from external registration (index.ts).
|
|
5
|
+
* This prevents double-registration when index.ts registers at startup
|
|
6
|
+
* and ensureRegistered() would register again on first tool call.
|
|
7
|
+
*/
|
|
8
|
+
export declare function setAgentName(name: string): void;
|
|
4
9
|
export { myAgentName };
|
|
5
10
|
export declare function registerAgentInboxTools(server: McpServer): void;
|
|
@@ -4,11 +4,28 @@ import { logActivity, errorResponse, successResponse, now, broadcastChange } fro
|
|
|
4
4
|
import { registerAgent as doRegister, getAgent, listAgents } from '../agent-registry.js';
|
|
5
5
|
/** The name assigned to this agent after registration */
|
|
6
6
|
let myAgentName = null;
|
|
7
|
-
/**
|
|
7
|
+
/** Set the agent name from external registration (index.ts).
|
|
8
|
+
* This prevents double-registration when index.ts registers at startup
|
|
9
|
+
* and ensureRegistered() would register again on first tool call.
|
|
10
|
+
*/
|
|
11
|
+
export function setAgentName(name) {
|
|
12
|
+
myAgentName = name;
|
|
13
|
+
}
|
|
14
|
+
/** Get or auto-register the agent name.
|
|
15
|
+
* Verifies the cached registration is still valid — if the liveness
|
|
16
|
+
* checker disconnected us while sleeping, re-register to reclaim identity.
|
|
17
|
+
*/
|
|
8
18
|
function ensureRegistered() {
|
|
9
|
-
if (
|
|
10
|
-
|
|
19
|
+
if (myAgentName) {
|
|
20
|
+
// Verify our registration is still active
|
|
21
|
+
const agent = getAgent(myAgentName);
|
|
22
|
+
if (agent && agent.status === 'connected' && agent.pid === process.ppid) {
|
|
23
|
+
return myAgentName;
|
|
24
|
+
}
|
|
25
|
+
// Stale — re-register
|
|
26
|
+
myAgentName = null;
|
|
11
27
|
}
|
|
28
|
+
myAgentName = doRegister();
|
|
12
29
|
return myAgentName;
|
|
13
30
|
}
|
|
14
31
|
export { myAgentName };
|
package/dist/tools/terminal.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/** Validate keys before sending — blocks shell escape patterns */
|
|
3
|
+
export declare function validateKeys(keys: string): string | null;
|
|
2
4
|
export declare function registerTerminalTools(server: McpServer): void;
|
package/dist/tools/terminal.js
CHANGED
|
@@ -2,13 +2,34 @@ import { z } from 'zod';
|
|
|
2
2
|
import { execFileSync } from 'child_process';
|
|
3
3
|
import { getDb } from '../db.js';
|
|
4
4
|
import { logActivity, errorResponse, successResponse } from '../helpers.js';
|
|
5
|
-
|
|
5
|
+
const MAX_KEYS_LENGTH = 200;
|
|
6
|
+
// Patterns that should never be sent via remote key injection
|
|
7
|
+
const BLOCKED_PATTERNS = [
|
|
8
|
+
/^\s*!/, // Claude Code shell escape (! command)
|
|
9
|
+
/;\s*!/, // shell escape after semicolon
|
|
10
|
+
];
|
|
11
|
+
/** Validate keys before sending — blocks shell escape patterns */
|
|
12
|
+
export function validateKeys(keys) {
|
|
13
|
+
if (keys.length > MAX_KEYS_LENGTH) {
|
|
14
|
+
return `Keys too long (${keys.length} chars, max ${MAX_KEYS_LENGTH}) — potential injection`;
|
|
15
|
+
}
|
|
16
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
17
|
+
if (pattern.test(keys)) {
|
|
18
|
+
return 'Blocked: shell escape pattern detected (! prefix). Use the terminal directly for shell commands.';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null; // valid
|
|
22
|
+
}
|
|
23
|
+
function getAgentPane(agentName, requireConnected = false) {
|
|
6
24
|
const db = getDb();
|
|
7
25
|
const agent = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(agentName);
|
|
8
26
|
if (!agent)
|
|
9
27
|
return { error: errorResponse(`Agent "${agentName}" not found`, 'NOT_FOUND') };
|
|
10
28
|
if (!agent.tmux_pane)
|
|
11
29
|
return { error: errorResponse(`Agent "${agentName}" has no tmux pane`, 'VALIDATION_ERROR') };
|
|
30
|
+
if (requireConnected && agent.status !== 'connected') {
|
|
31
|
+
return { error: errorResponse(`Agent "${agentName}" is disconnected — sending keys to a bare shell is blocked for security`, 'VALIDATION_ERROR') };
|
|
32
|
+
}
|
|
12
33
|
return { agent };
|
|
13
34
|
}
|
|
14
35
|
export function registerTerminalTools(server) {
|
|
@@ -36,9 +57,13 @@ export function registerTerminalTools(server) {
|
|
|
36
57
|
enter: z.boolean().optional().describe('Whether to press Enter after the keys (default: true)'),
|
|
37
58
|
literal: z.boolean().optional().describe('Send as literal text with -l flag (default: true). Set false for tmux key names like Escape, Up, Down, Left, Right, BTab'),
|
|
38
59
|
}, { destructiveHint: true }, async (params) => {
|
|
39
|
-
const result = getAgentPane(params.agent_name);
|
|
60
|
+
const result = getAgentPane(params.agent_name, true);
|
|
40
61
|
if (result.error)
|
|
41
62
|
return result.error;
|
|
63
|
+
// Validate keys for injection attacks
|
|
64
|
+
const violation = validateKeys(params.keys);
|
|
65
|
+
if (violation)
|
|
66
|
+
return errorResponse(violation, 'VALIDATION_ERROR');
|
|
42
67
|
const sendEnter = params.enter !== false;
|
|
43
68
|
const isLiteral = params.literal !== false;
|
|
44
69
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dalmasonto/taskflow-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.19",
|
|
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": {
|