@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.
@@ -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.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
- 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
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
- export {};
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
- // 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
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
- const PROMPT_PATTERNS = [
55
- /Esc to cancel/i,
56
- /Tab to amend/i,
57
- /\(y\/n\)/i,
58
- /\(yes\/no\)/i,
59
- /Do you want to/i,
60
- /Allow .+ for this/i,
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
- 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');
64
72
  const hints = [];
65
- 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)
66
76
  hints.push('Esc to cancel');
67
- if (/Tab to amend/i.test(tail))
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
- 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;
72
83
  return { detected, hints };
73
84
  }
74
- function pollAgentPrompts(prevState) {
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 output = execFileSync('tmux', ['capture-pane', '-p', '-S', '-', '-t', agent.tmux_pane], { timeout: 5000 }).toString();
80
- const { detected, hints } = detectPrompt(output);
81
- const wasAwaiting = prevState.get(agent.name) ?? false;
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
- // Transition: not awaiting → awaiting — fire notification + broadcast
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
- // Transition: awaiting → resolved
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 prevState) {
138
+ for (const [name] of promptState) {
104
139
  if (!agents.some(a => a.name === name)) {
105
- prevState.delete(name);
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
- // Prompt detection — poll connected agents for input-required state
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
- pollAgentPrompts(promptState);
584
+ pollAgentTerminals(promptState, contentHashes);
538
585
  }
539
586
  catch { /* ignore */ }
540
- }, 5_000);
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
- /** 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.17",
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": {