@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.
@@ -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 (preserves history)
61
- const disconnected = entries.find(e => e.status === 'disconnected');
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
- const CONFIG_PATH = resolve(homedir(), '.taskflow_config.json');
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
- // Auto-register this agent
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
- 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
- ];
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
- const tail = content.split('\n').slice(-25).join('\n');
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
- if (/Esc to cancel/i.test(tail))
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 (/Tab to amend/i.test(tail))
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
- const detected = PROMPT_PATTERNS.some(p => p.test(tail));
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
- // Register with remote relay so it knows where to proxy commands
666
+ // Relay command polling + state pushing (if relay is configured)
576
667
  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
- });
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
- /** Get or auto-register the agent name */
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 (!myAgentName) {
10
- myAgentName = doRegister();
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 };
@@ -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;
@@ -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
- function getAgentPane(agentName) {
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.18",
3
+ "version": "1.0.20",
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",