@dalmasonto/taskflow-mcp 1.0.18 → 1.0.20
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.js +8 -1
- package/dist/index.js +3 -1
- package/dist/sse.js +147 -26
- 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 +1 -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.js
CHANGED
|
@@ -24,7 +24,14 @@ const DEFAULTS = {
|
|
|
24
24
|
relayPushToken: '',
|
|
25
25
|
};
|
|
26
26
|
// ─── config file path ────────────────────────────────────────────────
|
|
27
|
-
|
|
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
|
|
28
35
|
// ─── loader ──────────────────────────────────────────────────────────
|
|
29
36
|
function loadFromFile() {
|
|
30
37
|
try {
|
package/dist/index.js
CHANGED
|
@@ -140,8 +140,10 @@ if (!httpOnly) {
|
|
|
140
140
|
const transport = new StdioServerTransport();
|
|
141
141
|
await server.connect(transport);
|
|
142
142
|
const { registerAgent, unregisterAgent } = await import('./agent-registry.js');
|
|
143
|
-
|
|
143
|
+
const { setAgentName } = await import('./tools/agent-inbox.js');
|
|
144
|
+
// Auto-register this agent and sync name to agent-inbox tools
|
|
144
145
|
const agentName = registerAgent();
|
|
146
|
+
setAgentName(agentName);
|
|
145
147
|
const agentPid = process.ppid;
|
|
146
148
|
console.error(`[agent] registered as "${agentName}"`);
|
|
147
149
|
let cleanup = () => { try {
|
package/dist/sse.js
CHANGED
|
@@ -3,6 +3,7 @@ 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;
|
|
8
9
|
// ─── Relay upstream config (from config file, env vars, or .env) ────
|
|
@@ -53,24 +54,32 @@ function readBody(req) {
|
|
|
53
54
|
req.on('end', () => resolve(body));
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
*/
|
|
64
68
|
function detectPrompt(content) {
|
|
65
|
-
|
|
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');
|
|
66
72
|
const hints = [];
|
|
67
|
-
|
|
73
|
+
const hasEsc = /Esc to cancel/i.test(tail);
|
|
74
|
+
const hasTab = /Tab to amend/i.test(tail);
|
|
75
|
+
if (hasEsc)
|
|
68
76
|
hints.push('Esc to cancel');
|
|
69
|
-
if (
|
|
77
|
+
if (hasTab)
|
|
70
78
|
hints.push('Tab to amend');
|
|
71
79
|
if (/shift\+tab/i.test(tail))
|
|
72
80
|
hints.push('Shift+Tab');
|
|
73
|
-
|
|
81
|
+
// Only flag as detected if we see the actual prompt footer
|
|
82
|
+
const detected = hasEsc || hasTab;
|
|
74
83
|
return { detected, hints };
|
|
75
84
|
}
|
|
76
85
|
/** Fast string hash (djb2) for content change detection */
|
|
@@ -133,6 +142,78 @@ function pollAgentTerminals(promptState, contentHashes) {
|
|
|
133
142
|
}
|
|
134
143
|
}
|
|
135
144
|
}
|
|
145
|
+
// ─── Relay command executor ─────────────────────────────────────────
|
|
146
|
+
// Executes commands received from the relay's command queue locally.
|
|
147
|
+
async function executeRelayCommand(type, payload) {
|
|
148
|
+
const db = getDb();
|
|
149
|
+
try {
|
|
150
|
+
switch (type) {
|
|
151
|
+
case 'send-keys': {
|
|
152
|
+
const { agentName, keys, enter = true, literal = true } = payload;
|
|
153
|
+
const { validateKeys } = await import('./tools/terminal.js');
|
|
154
|
+
const violation = validateKeys(keys);
|
|
155
|
+
if (violation)
|
|
156
|
+
return { status: 'error', result: violation };
|
|
157
|
+
const agent = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(agentName);
|
|
158
|
+
if (!agent?.tmux_pane)
|
|
159
|
+
return { status: 'error', result: `Agent "${agentName}" not found or has no pane` };
|
|
160
|
+
if (agent.status !== 'connected')
|
|
161
|
+
return { status: 'error', result: `Agent "${agentName}" is disconnected` };
|
|
162
|
+
const pane = agent.tmux_pane;
|
|
163
|
+
if (literal) {
|
|
164
|
+
execFileSync('tmux', ['send-keys', '-t', pane, '-l', keys], { stdio: 'ignore', timeout: 5000 });
|
|
165
|
+
if (enter)
|
|
166
|
+
execFileSync('tmux', ['send-keys', '-t', pane, 'Enter'], { stdio: 'ignore', timeout: 5000 });
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const args = ['send-keys', '-t', pane, keys];
|
|
170
|
+
if (enter)
|
|
171
|
+
args.push('Enter');
|
|
172
|
+
execFileSync('tmux', args, { stdio: 'ignore', timeout: 5000 });
|
|
173
|
+
}
|
|
174
|
+
logActivity('terminal_send_keys', `[relay] Sent keys to ${agentName}: ${keys.slice(0, 50)}`, { entityType: 'agent' });
|
|
175
|
+
return { status: 'done', result: { agent: agentName, keys, sent: true } };
|
|
176
|
+
}
|
|
177
|
+
case 'respond-message': {
|
|
178
|
+
const { messageId, response } = payload;
|
|
179
|
+
const ts = new Date().toISOString();
|
|
180
|
+
const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(messageId);
|
|
181
|
+
if (!msg)
|
|
182
|
+
return { status: 'error', result: `Message ${messageId} not found` };
|
|
183
|
+
if (msg.status !== 'pending')
|
|
184
|
+
return { status: 'error', result: `Message ${messageId} is already ${msg.status}` };
|
|
185
|
+
db.prepare('UPDATE agent_messages SET response = ?, status = ?, answered_at = ? WHERE id = ?')
|
|
186
|
+
.run(response, 'answered', ts, messageId);
|
|
187
|
+
const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(messageId);
|
|
188
|
+
broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
|
|
189
|
+
return { status: 'done', result: { id: messageId, status: 'answered' } };
|
|
190
|
+
}
|
|
191
|
+
case 'dismiss-message': {
|
|
192
|
+
const { messageId } = payload;
|
|
193
|
+
const ts = new Date().toISOString();
|
|
194
|
+
db.prepare("UPDATE agent_messages SET status = 'dismissed', answered_at = ? WHERE id = ?").run(ts, payload.messageId);
|
|
195
|
+
const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(messageId);
|
|
196
|
+
broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
|
|
197
|
+
return { status: 'done', result: { id: messageId, status: 'dismissed' } };
|
|
198
|
+
}
|
|
199
|
+
case 'send-to-agent': {
|
|
200
|
+
const { recipient, message, projectId } = payload;
|
|
201
|
+
const ts = new Date().toISOString();
|
|
202
|
+
const result = db.prepare(`INSERT INTO agent_messages (project_id, question, sender_name, recipient_name, source, status, created_at)
|
|
203
|
+
VALUES (?, ?, 'user', ?, 'ui', 'pending', ?)`).run(projectId ?? null, message, recipient, ts);
|
|
204
|
+
const id = result.lastInsertRowid;
|
|
205
|
+
const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
|
|
206
|
+
broadcast('agent_question', { entity: 'agent_message', action: 'agent_question', payload: msg });
|
|
207
|
+
return { status: 'done', result: { id, recipient } };
|
|
208
|
+
}
|
|
209
|
+
default:
|
|
210
|
+
return { status: 'error', result: `Unknown command type: ${type}` };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
return { status: 'error', result: err.message };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
136
217
|
export async function startSSEServer() {
|
|
137
218
|
const server = createServer(async (req, res) => {
|
|
138
219
|
// CORS headers for all requests
|
|
@@ -466,6 +547,10 @@ export async function startSSEServer() {
|
|
|
466
547
|
jsonResponse(res, 400, { error: `Agent "${agentName}" has no tmux pane` });
|
|
467
548
|
return;
|
|
468
549
|
}
|
|
550
|
+
if (agent.status !== 'connected') {
|
|
551
|
+
jsonResponse(res, 403, { error: `Agent "${agentName}" is disconnected — sending keys to a bare shell is blocked for security` });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
469
554
|
let body;
|
|
470
555
|
try {
|
|
471
556
|
body = JSON.parse(await readBody(req));
|
|
@@ -481,6 +566,12 @@ export async function startSSEServer() {
|
|
|
481
566
|
jsonResponse(res, 400, { error: 'keys must be a string' });
|
|
482
567
|
return;
|
|
483
568
|
}
|
|
569
|
+
// Block shell escape patterns and oversized payloads
|
|
570
|
+
const violation = validateKeys(keys);
|
|
571
|
+
if (violation) {
|
|
572
|
+
jsonResponse(res, 403, { error: violation });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
484
575
|
try {
|
|
485
576
|
const pane = agent.tmux_pane;
|
|
486
577
|
if (literal) {
|
|
@@ -572,21 +663,51 @@ export async function startSSEServer() {
|
|
|
572
663
|
else {
|
|
573
664
|
console.log(`[SSE] listening on port ${port}`);
|
|
574
665
|
}
|
|
575
|
-
//
|
|
666
|
+
// Relay command polling + state pushing (if relay is configured)
|
|
576
667
|
if (RELAY_URL && RELAY_PUSH_TOKEN) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
668
|
+
console.log(`[relay] command polling started → ${RELAY_URL}`);
|
|
669
|
+
// Push state snapshot on startup and every 30s
|
|
670
|
+
const pushState = () => {
|
|
671
|
+
try {
|
|
672
|
+
const db = getDb();
|
|
673
|
+
const state = {
|
|
674
|
+
tasks: db.prepare('SELECT * FROM tasks').all(),
|
|
675
|
+
projects: db.prepare('SELECT * FROM projects').all(),
|
|
676
|
+
sessions: db.prepare('SELECT * FROM sessions').all(),
|
|
677
|
+
activityLogs: db.prepare('SELECT * FROM activity_logs ORDER BY id DESC LIMIT 100').all(),
|
|
678
|
+
agentMessages: db.prepare('SELECT * FROM agent_messages ORDER BY id DESC LIMIT 200').all(),
|
|
679
|
+
agentRegistry: db.prepare('SELECT * FROM agent_registry').all(),
|
|
680
|
+
};
|
|
681
|
+
fetch(`${RELAY_URL}/push/state`, {
|
|
682
|
+
method: 'POST',
|
|
683
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_PUSH_TOKEN}` },
|
|
684
|
+
body: JSON.stringify(state),
|
|
685
|
+
}).catch(() => { });
|
|
686
|
+
}
|
|
687
|
+
catch { /* ignore */ }
|
|
688
|
+
};
|
|
689
|
+
pushState();
|
|
690
|
+
setInterval(pushState, 30_000);
|
|
691
|
+
// Poll for commands every 2s
|
|
692
|
+
setInterval(async () => {
|
|
693
|
+
try {
|
|
694
|
+
const res = await fetch(`${RELAY_URL}/commands/pending`, {
|
|
695
|
+
headers: { 'Authorization': `Bearer ${RELAY_PUSH_TOKEN}` },
|
|
696
|
+
});
|
|
697
|
+
if (!res.ok)
|
|
698
|
+
return;
|
|
699
|
+
const commands = await res.json();
|
|
700
|
+
for (const cmd of commands) {
|
|
701
|
+
const result = await executeRelayCommand(cmd.type, cmd.payload);
|
|
702
|
+
fetch(`${RELAY_URL}/commands/${cmd.id}/done`, {
|
|
703
|
+
method: 'POST',
|
|
704
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_PUSH_TOKEN}` },
|
|
705
|
+
body: JSON.stringify(result),
|
|
706
|
+
}).catch(() => { });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch { /* relay may be down */ }
|
|
710
|
+
}, 2_000);
|
|
590
711
|
}
|
|
591
712
|
resolve();
|
|
592
713
|
});
|
|
@@ -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 {
|