@dalmasonto/taskflow-mcp 1.0.11 → 1.0.14
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.d.ts +5 -1
- package/dist/agent-registry.js +29 -25
- package/dist/config.d.ts +19 -0
- package/dist/config.js +108 -0
- package/dist/db.js +23 -2
- package/dist/index.js +70 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +76 -0
- package/dist/sse.js +113 -18
- package/dist/tmux-bridge.js +14 -5
- package/dist/tools/activity.d.ts +10 -0
- package/dist/tools/activity.js +95 -3
- package/dist/tools/agent-inbox.js +8 -8
- package/dist/tools/agent.js +2 -2
- package/dist/tools/analytics.js +86 -3
- package/dist/tools/checkpoint.d.ts +27 -0
- package/dist/tools/checkpoint.js +105 -0
- package/dist/tools/notifications.js +4 -4
- package/dist/tools/projects.js +6 -6
- package/dist/tools/settings.js +24 -3
- package/dist/tools/tasks.d.ts +2 -0
- package/dist/tools/tasks.js +14 -8
- package/dist/tools/terminal.d.ts +2 -0
- package/dist/tools/terminal.js +60 -0
- package/dist/tools/timer.js +4 -4
- package/dist/types.d.ts +5 -1
- package/dist/types.js +2 -0
- package/package.json +1 -1
package/dist/agent-registry.d.ts
CHANGED
|
@@ -8,7 +8,11 @@ interface AgentRow {
|
|
|
8
8
|
connected_at: string;
|
|
9
9
|
disconnected_at: string | null;
|
|
10
10
|
}
|
|
11
|
-
/** Register an agent. Returns the assigned name.
|
|
11
|
+
/** Register an agent. Returns the assigned name.
|
|
12
|
+
* Uses project_path as the stable identifier — same directory reuses
|
|
13
|
+
* disconnected entries, preserving message history across sessions.
|
|
14
|
+
* Multiple concurrent agents in the same project get suffixed names.
|
|
15
|
+
*/
|
|
12
16
|
export declare function registerAgent(options?: {
|
|
13
17
|
customName?: string;
|
|
14
18
|
}): string;
|
package/dist/agent-registry.js
CHANGED
|
@@ -41,39 +41,43 @@ function generateName(folderName) {
|
|
|
41
41
|
}
|
|
42
42
|
return `${folderName}:${Date.now()}`;
|
|
43
43
|
}
|
|
44
|
-
/** Register an agent. Returns the assigned name.
|
|
44
|
+
/** Register an agent. Returns the assigned name.
|
|
45
|
+
* Uses project_path as the stable identifier — same directory reuses
|
|
46
|
+
* disconnected entries, preserving message history across sessions.
|
|
47
|
+
* Multiple concurrent agents in the same project get suffixed names.
|
|
48
|
+
*/
|
|
45
49
|
export function registerAgent(options) {
|
|
46
50
|
const db = getDb();
|
|
47
51
|
const agentPid = process.ppid;
|
|
48
52
|
const projectPath = process.cwd();
|
|
49
53
|
const folderName = projectPath.split('/').pop() || 'unknown';
|
|
50
|
-
// Check if this PID already has a registration — reuse it (or rename if customName provided)
|
|
51
|
-
const existingByPid = db.prepare('SELECT * FROM agent_registry WHERE pid = ? AND status = ?').get(agentPid, 'connected');
|
|
52
|
-
if (existingByPid) {
|
|
53
|
-
const tmuxPane = detectTmuxPane(agentPid);
|
|
54
|
-
if (tmuxPane !== existingByPid.tmux_pane) {
|
|
55
|
-
db.prepare('UPDATE agent_registry SET tmux_pane = ? WHERE id = ?').run(tmuxPane, existingByPid.id);
|
|
56
|
-
}
|
|
57
|
-
// Allow renaming via customName
|
|
58
|
-
if (options?.customName && options.customName !== existingByPid.name) {
|
|
59
|
-
db.prepare('UPDATE agent_registry SET name = ? WHERE id = ?').run(options.customName, existingByPid.id);
|
|
60
|
-
broadcast('agent_connected', { entity: 'agent', action: 'agent_connected', payload: { ...existingByPid, name: options.customName, tmux_pane: tmuxPane ?? existingByPid.tmux_pane } });
|
|
61
|
-
return options.customName;
|
|
62
|
-
}
|
|
63
|
-
return existingByPid.name;
|
|
64
|
-
}
|
|
65
|
-
// Clean up dead agents first to free up names
|
|
66
|
-
checkAgentLiveness();
|
|
67
|
-
const name = options?.customName || generateName(folderName);
|
|
68
54
|
const tmuxPane = detectTmuxPane(agentPid);
|
|
69
55
|
const ts = new Date().toISOString();
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
56
|
+
// Clean up dead agents first so we can reuse their entries
|
|
57
|
+
checkAgentLiveness();
|
|
58
|
+
// Find all entries for this project path
|
|
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');
|
|
62
|
+
if (disconnected) {
|
|
63
|
+
const newName = options?.customName || disconnected.name;
|
|
64
|
+
const renamed = newName !== disconnected.name;
|
|
65
|
+
// Update agent_messages if renaming to preserve history
|
|
66
|
+
if (renamed) {
|
|
67
|
+
db.prepare('UPDATE agent_messages SET sender_name = ? WHERE sender_name = ?').run(newName, disconnected.name);
|
|
68
|
+
db.prepare('UPDATE agent_messages SET recipient_name = ? WHERE recipient_name = ?').run(newName, disconnected.name);
|
|
69
|
+
}
|
|
70
|
+
db.prepare('UPDATE agent_registry SET name = ?, pid = ?, tmux_pane = ?, status = ?, connected_at = ?, disconnected_at = NULL WHERE id = ?').run(newName, agentPid, tmuxPane, 'connected', ts, disconnected.id);
|
|
71
|
+
const row = db.prepare('SELECT * FROM agent_registry WHERE id = ?').get(disconnected.id);
|
|
72
|
+
broadcast('agent_connected', { entity: 'agent', action: 'agent_connected', payload: row });
|
|
73
|
+
logActivity('agent_connected', `Agent "${newName}" reconnected`, { entityType: 'agent' });
|
|
74
|
+
return newName;
|
|
76
75
|
}
|
|
76
|
+
// Priority 2: All entries for this path are connected — this is a concurrent agent
|
|
77
|
+
// Generate a suffixed name to avoid collision
|
|
78
|
+
const baseName = options?.customName || folderName;
|
|
79
|
+
const name = entries.length === 0 ? baseName : generateName(baseName);
|
|
80
|
+
db.prepare('INSERT INTO agent_registry (name, project_path, pid, tmux_pane, status, connected_at) VALUES (?, ?, ?, ?, ?, ?)').run(name, projectPath, agentPid, tmuxPane, 'connected', ts);
|
|
77
81
|
const row = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(name);
|
|
78
82
|
broadcast('agent_connected', { entity: 'agent', action: 'agent_connected', payload: row });
|
|
79
83
|
logActivity('agent_connected', `Agent "${name}" connected`, { entityType: 'agent' });
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface TaskFlowConfig {
|
|
2
|
+
port: number;
|
|
3
|
+
host: string;
|
|
4
|
+
databasePath: string;
|
|
5
|
+
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
|
6
|
+
agentLivenessInterval: number;
|
|
7
|
+
maxPortAttempts: number;
|
|
8
|
+
}
|
|
9
|
+
/** Keys that live in the config file (not SQLite) */
|
|
10
|
+
export declare const SERVER_CONFIG_KEYS: Set<string>;
|
|
11
|
+
export declare function getConfig(): TaskFlowConfig;
|
|
12
|
+
/** Check if a setting key is a server-level config key */
|
|
13
|
+
export declare function isServerConfigKey(key: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Update a single key in ~/.taskflow_config.json.
|
|
16
|
+
* Re-reads the file first to avoid clobbering other keys.
|
|
17
|
+
* Returns the updated config value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeConfigKey(key: string, value: unknown): void;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
/** Keys that live in the config file (not SQLite) */
|
|
5
|
+
export const SERVER_CONFIG_KEYS = new Set([
|
|
6
|
+
'port',
|
|
7
|
+
'host',
|
|
8
|
+
'databasePath',
|
|
9
|
+
'logLevel',
|
|
10
|
+
'agentLivenessInterval',
|
|
11
|
+
'maxPortAttempts',
|
|
12
|
+
]);
|
|
13
|
+
// ─── defaults ────────────────────────────────────────────────────────
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
port: 3456,
|
|
16
|
+
host: '127.0.0.1',
|
|
17
|
+
databasePath: '~/.taskflow/taskflow.db',
|
|
18
|
+
logLevel: 'info',
|
|
19
|
+
agentLivenessInterval: 30_000,
|
|
20
|
+
maxPortAttempts: 10,
|
|
21
|
+
};
|
|
22
|
+
// ─── config file path ────────────────────────────────────────────────
|
|
23
|
+
const CONFIG_PATH = resolve(homedir(), '.taskflow_config.json');
|
|
24
|
+
// ─── loader ──────────────────────────────────────────────────────────
|
|
25
|
+
function loadFromFile() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
30
|
+
console.error(`[config] Warning: ${CONFIG_PATH} is not a JSON object — using defaults`);
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err.code === 'ENOENT') {
|
|
37
|
+
// File doesn't exist — that's fine, use defaults
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
console.error(`[config] Warning: failed to read ${CONFIG_PATH} — ${err.message}`);
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function applyCliAndEnvOverrides(config) {
|
|
45
|
+
// Env vars override config file
|
|
46
|
+
if (process.env.TASKFLOW_SSE_PORT) {
|
|
47
|
+
const p = parseInt(process.env.TASKFLOW_SSE_PORT, 10);
|
|
48
|
+
if (!isNaN(p))
|
|
49
|
+
config.port = p;
|
|
50
|
+
}
|
|
51
|
+
if (process.env.TASKFLOW_DB_PATH) {
|
|
52
|
+
config.databasePath = process.env.TASKFLOW_DB_PATH;
|
|
53
|
+
}
|
|
54
|
+
if (process.env.TASKFLOW_HOST) {
|
|
55
|
+
config.host = process.env.TASKFLOW_HOST;
|
|
56
|
+
}
|
|
57
|
+
if (process.env.TASKFLOW_LOG_LEVEL) {
|
|
58
|
+
config.logLevel = process.env.TASKFLOW_LOG_LEVEL;
|
|
59
|
+
}
|
|
60
|
+
// CLI args take highest priority
|
|
61
|
+
const portArgIdx = process.argv.indexOf('--port');
|
|
62
|
+
if (portArgIdx !== -1 && process.argv[portArgIdx + 1]) {
|
|
63
|
+
const p = parseInt(process.argv[portArgIdx + 1], 10);
|
|
64
|
+
if (!isNaN(p))
|
|
65
|
+
config.port = p;
|
|
66
|
+
}
|
|
67
|
+
const hostArgIdx = process.argv.indexOf('--host');
|
|
68
|
+
if (hostArgIdx !== -1 && process.argv[hostArgIdx + 1]) {
|
|
69
|
+
config.host = process.argv[hostArgIdx + 1];
|
|
70
|
+
}
|
|
71
|
+
return config;
|
|
72
|
+
}
|
|
73
|
+
// ─── singleton ───────────────────────────────────────────────────────
|
|
74
|
+
let _config = null;
|
|
75
|
+
export function getConfig() {
|
|
76
|
+
if (_config)
|
|
77
|
+
return _config;
|
|
78
|
+
const fileValues = loadFromFile();
|
|
79
|
+
_config = applyCliAndEnvOverrides({ ...DEFAULTS, ...fileValues });
|
|
80
|
+
return _config;
|
|
81
|
+
}
|
|
82
|
+
/** Check if a setting key is a server-level config key */
|
|
83
|
+
export function isServerConfigKey(key) {
|
|
84
|
+
return SERVER_CONFIG_KEYS.has(key);
|
|
85
|
+
}
|
|
86
|
+
// ─── write-back ──────────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* Update a single key in ~/.taskflow_config.json.
|
|
89
|
+
* Re-reads the file first to avoid clobbering other keys.
|
|
90
|
+
* Returns the updated config value.
|
|
91
|
+
*/
|
|
92
|
+
export function writeConfigKey(key, value) {
|
|
93
|
+
let existing = {};
|
|
94
|
+
try {
|
|
95
|
+
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
|
96
|
+
existing = JSON.parse(raw);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// File doesn't exist or is malformed — start fresh
|
|
100
|
+
}
|
|
101
|
+
existing[key] = value;
|
|
102
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
103
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
|
|
104
|
+
// Update the in-memory singleton so get_setting reflects the change immediately
|
|
105
|
+
if (_config && key in DEFAULTS) {
|
|
106
|
+
_config[key] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -2,7 +2,7 @@ import Database from 'better-sqlite3';
|
|
|
2
2
|
import { mkdirSync } from 'fs';
|
|
3
3
|
import { dirname, resolve } from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
|
-
|
|
5
|
+
import { getConfig } from './config.js';
|
|
6
6
|
let db = null;
|
|
7
7
|
// Expands ~/path to $HOME/path. Only handles ~/ prefix, not ~user/ paths.
|
|
8
8
|
export function resolvePath(p) {
|
|
@@ -14,7 +14,7 @@ export function resolvePath(p) {
|
|
|
14
14
|
export function getDb() {
|
|
15
15
|
if (db)
|
|
16
16
|
return db;
|
|
17
|
-
return initDb(
|
|
17
|
+
return initDb(getConfig().databasePath);
|
|
18
18
|
}
|
|
19
19
|
// For testing: initialize with a specific path (use ':memory:' for tests)
|
|
20
20
|
export function initDb(path) {
|
|
@@ -131,6 +131,18 @@ function initSchema(db) {
|
|
|
131
131
|
CREATE INDEX IF NOT EXISTS idx_agent_messages_project_id ON agent_messages(project_id);
|
|
132
132
|
CREATE INDEX IF NOT EXISTS idx_agent_registry_status ON agent_registry(status);
|
|
133
133
|
CREATE INDEX IF NOT EXISTS idx_agent_registry_name ON agent_registry(name);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_agent_registry_project_path ON agent_registry(project_path);
|
|
135
|
+
|
|
136
|
+
CREATE TABLE IF NOT EXISTS tool_executions (
|
|
137
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
138
|
+
tool_name TEXT NOT NULL,
|
|
139
|
+
duration_ms INTEGER NOT NULL,
|
|
140
|
+
success INTEGER NOT NULL DEFAULT 1,
|
|
141
|
+
error_message TEXT,
|
|
142
|
+
created_at TEXT NOT NULL
|
|
143
|
+
);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_tool_executions_tool_name ON tool_executions(tool_name);
|
|
145
|
+
CREATE INDEX IF NOT EXISTS idx_tool_executions_created_at ON tool_executions(created_at);
|
|
134
146
|
`);
|
|
135
147
|
// Migrations — add columns that may be missing on existing databases
|
|
136
148
|
const cols = db.prepare("PRAGMA table_info(agent_messages)").all();
|
|
@@ -184,6 +196,15 @@ function initSchema(db) {
|
|
|
184
196
|
}
|
|
185
197
|
}
|
|
186
198
|
catch { /* table may not exist yet */ }
|
|
199
|
+
// Migration: add allowed_tools and denied_tools to tasks
|
|
200
|
+
const taskCols = db.prepare("PRAGMA table_info(tasks)").all();
|
|
201
|
+
const taskColNames = new Set(taskCols.map(c => c.name));
|
|
202
|
+
if (!taskColNames.has('allowed_tools')) {
|
|
203
|
+
db.exec("ALTER TABLE tasks ADD COLUMN allowed_tools TEXT");
|
|
204
|
+
}
|
|
205
|
+
if (!taskColNames.has('denied_tools')) {
|
|
206
|
+
db.exec("ALTER TABLE tasks ADD COLUMN denied_tools TEXT");
|
|
207
|
+
}
|
|
187
208
|
}
|
|
188
209
|
export function closeDb() {
|
|
189
210
|
if (db) {
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { getConfig } from './config.js';
|
|
2
3
|
import { startSSEServer } from './sse.js';
|
|
3
4
|
import { getDb } from './db.js';
|
|
4
5
|
import { broadcast } from './sse.js';
|
|
@@ -34,13 +35,14 @@ cleanupOrphanedSessions();
|
|
|
34
35
|
// Always start the HTTP/SSE server (probes for existing instances, finds fallback port)
|
|
35
36
|
await startSSEServer();
|
|
36
37
|
// Liveness checker — periodically check registered agents and mark dead ones
|
|
38
|
+
const cfg = getConfig();
|
|
37
39
|
setInterval(async () => {
|
|
38
40
|
try {
|
|
39
41
|
const { checkAgentLiveness } = await import('./agent-registry.js');
|
|
40
42
|
checkAgentLiveness();
|
|
41
43
|
}
|
|
42
44
|
catch { /* ignore */ }
|
|
43
|
-
},
|
|
45
|
+
}, cfg.agentLivenessInterval);
|
|
44
46
|
// Only start MCP stdio transport when not in http-only mode
|
|
45
47
|
if (!httpOnly) {
|
|
46
48
|
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
@@ -54,10 +56,74 @@ if (!httpOnly) {
|
|
|
54
56
|
const { registerSettingsTools } = await import('./tools/settings.js');
|
|
55
57
|
const { registerAgentTools } = await import('./tools/agent.js');
|
|
56
58
|
const { registerAgentInboxTools } = await import('./tools/agent-inbox.js');
|
|
59
|
+
const { registerTerminalTools } = await import('./tools/terminal.js');
|
|
60
|
+
const { registerResources } = await import('./resources.js');
|
|
61
|
+
const { registerCheckpointTools } = await import('./tools/checkpoint.js');
|
|
57
62
|
const server = new McpServer({
|
|
58
63
|
name: 'taskflow',
|
|
59
64
|
version: '1.0.0',
|
|
60
65
|
});
|
|
66
|
+
// Wrap server.tool() to track execution time and log failures
|
|
67
|
+
const originalTool = server.tool.bind(server);
|
|
68
|
+
server.tool = function (...args) {
|
|
69
|
+
const toolName = args[0];
|
|
70
|
+
// Find the callback (always the last argument, and it's a function)
|
|
71
|
+
const cbIndex = args.length - 1;
|
|
72
|
+
const originalCb = args[cbIndex];
|
|
73
|
+
if (typeof originalCb === 'function') {
|
|
74
|
+
args[cbIndex] = async (...cbArgs) => {
|
|
75
|
+
// Check task-scoped tool allowlists if a timer is active
|
|
76
|
+
try {
|
|
77
|
+
const db = getDb();
|
|
78
|
+
const activeSession = db.prepare("SELECT task_id FROM sessions WHERE end IS NULL ORDER BY start DESC LIMIT 1").get();
|
|
79
|
+
if (activeSession) {
|
|
80
|
+
const task = db.prepare("SELECT allowed_tools, denied_tools FROM tasks WHERE id = ?").get(activeSession.task_id);
|
|
81
|
+
if (task) {
|
|
82
|
+
if (task.denied_tools) {
|
|
83
|
+
const denied = JSON.parse(task.denied_tools);
|
|
84
|
+
if (denied.includes(toolName)) {
|
|
85
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `Tool "${toolName}" is denied for task #${activeSession.task_id}`, code: 'TOOL_DENIED' }) }] };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (task.allowed_tools) {
|
|
89
|
+
const allowed = JSON.parse(task.allowed_tools);
|
|
90
|
+
if (!allowed.includes(toolName)) {
|
|
91
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `Tool "${toolName}" is not in the allowlist for task #${activeSession.task_id}`, code: 'TOOL_NOT_ALLOWED' }) }] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* don't break tool execution on allowlist check failure */ }
|
|
98
|
+
const start = Date.now();
|
|
99
|
+
try {
|
|
100
|
+
const result = await originalCb(...cbArgs);
|
|
101
|
+
const duration = Date.now() - start;
|
|
102
|
+
// Record execution and broadcast event
|
|
103
|
+
try {
|
|
104
|
+
const ts = new Date().toISOString();
|
|
105
|
+
const db = getDb();
|
|
106
|
+
db.prepare('INSERT INTO tool_executions (tool_name, duration_ms, success, created_at) VALUES (?, ?, 1, ?)').run(toolName, duration, ts);
|
|
107
|
+
broadcast('tool_executed', { entity: 'tool', action: 'tool_executed', payload: { tool_name: toolName, duration_ms: duration, success: true, created_at: ts } });
|
|
108
|
+
}
|
|
109
|
+
catch { /* don't break tool execution */ }
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const duration = Date.now() - start;
|
|
114
|
+
try {
|
|
115
|
+
const ts = new Date().toISOString();
|
|
116
|
+
const db = getDb();
|
|
117
|
+
db.prepare('INSERT INTO tool_executions (tool_name, duration_ms, success, error_message, created_at) VALUES (?, ?, 0, ?, ?)').run(toolName, duration, err.message ?? String(err), ts);
|
|
118
|
+
broadcast('tool_failed', { entity: 'tool', action: 'tool_failed', payload: { tool_name: toolName, duration_ms: duration, success: false, error: err.message, created_at: ts } });
|
|
119
|
+
}
|
|
120
|
+
catch { /* don't break tool execution */ }
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return originalTool.apply(server, args);
|
|
126
|
+
};
|
|
61
127
|
registerAgentTools(server);
|
|
62
128
|
registerTaskTools(server);
|
|
63
129
|
registerProjectTools(server);
|
|
@@ -67,6 +133,9 @@ if (!httpOnly) {
|
|
|
67
133
|
registerNotificationTools(server);
|
|
68
134
|
registerSettingsTools(server);
|
|
69
135
|
registerAgentInboxTools(server);
|
|
136
|
+
registerTerminalTools(server);
|
|
137
|
+
registerCheckpointTools(server);
|
|
138
|
+
registerResources(server);
|
|
70
139
|
const transport = new StdioServerTransport();
|
|
71
140
|
await server.connect(transport);
|
|
72
141
|
const { registerAgent, unregisterAgent } = await import('./agent-registry.js');
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { getDb } from './db.js';
|
|
3
|
+
export function registerResources(server) {
|
|
4
|
+
// ─── Projects Resource ──────────────────────────────────────────────
|
|
5
|
+
server.resource('projects', new ResourceTemplate('taskflow://projects/{id}', {
|
|
6
|
+
list: async () => {
|
|
7
|
+
const db = getDb();
|
|
8
|
+
const projects = db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all();
|
|
9
|
+
return {
|
|
10
|
+
resources: projects.map(p => ({
|
|
11
|
+
uri: `taskflow://projects/${p.id}`,
|
|
12
|
+
name: p.name,
|
|
13
|
+
description: p.description ?? undefined,
|
|
14
|
+
mimeType: 'application/json',
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
}), { description: 'TaskFlow projects', mimeType: 'application/json' }, async (uri, variables) => {
|
|
19
|
+
const db = getDb();
|
|
20
|
+
const id = Number(variables.id);
|
|
21
|
+
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
|
|
22
|
+
if (!project) {
|
|
23
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify({ error: 'Project not found' }), mimeType: 'application/json' }] };
|
|
24
|
+
}
|
|
25
|
+
// Include task count and recent tasks
|
|
26
|
+
const taskCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE project_id = ?').get(id);
|
|
27
|
+
const recentTasks = db.prepare('SELECT id, title, status, priority FROM tasks WHERE project_id = ? ORDER BY updated_at DESC LIMIT 10').all(id);
|
|
28
|
+
return {
|
|
29
|
+
contents: [{
|
|
30
|
+
uri: uri.href,
|
|
31
|
+
text: JSON.stringify({ ...project, task_count: taskCount.count, recent_tasks: recentTasks }, null, 2),
|
|
32
|
+
mimeType: 'application/json',
|
|
33
|
+
}],
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
// ─── Tasks Resource ─────────────────────────────────────────────────
|
|
37
|
+
server.resource('tasks', new ResourceTemplate('taskflow://tasks/{id}', {
|
|
38
|
+
list: async () => {
|
|
39
|
+
const db = getDb();
|
|
40
|
+
// List active tasks (not done) for discoverability
|
|
41
|
+
const tasks = db.prepare("SELECT * FROM tasks WHERE status != 'done' ORDER BY updated_at DESC LIMIT 50").all();
|
|
42
|
+
return {
|
|
43
|
+
resources: tasks.map(t => ({
|
|
44
|
+
uri: `taskflow://tasks/${t.id}`,
|
|
45
|
+
name: `[${t.status}] ${t.title}`,
|
|
46
|
+
description: t.description?.slice(0, 100) ?? undefined,
|
|
47
|
+
mimeType: 'application/json',
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
}), { description: 'TaskFlow tasks', mimeType: 'application/json' }, async (uri, variables) => {
|
|
52
|
+
const db = getDb();
|
|
53
|
+
const id = Number(variables.id);
|
|
54
|
+
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
55
|
+
if (!task) {
|
|
56
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify({ error: 'Task not found' }), mimeType: 'application/json' }] };
|
|
57
|
+
}
|
|
58
|
+
// Include time tracking and dependencies
|
|
59
|
+
const sessions = db.prepare('SELECT * FROM sessions WHERE task_id = ? ORDER BY start DESC LIMIT 5').all(id);
|
|
60
|
+
const totalTime = db.prepare('SELECT SUM(CASE WHEN end IS NOT NULL THEN (julianday(end) - julianday(start)) * 86400000 ELSE 0 END) as total FROM sessions WHERE task_id = ?').get(id);
|
|
61
|
+
const deps = db.prepare('SELECT dependency_id FROM task_dependencies WHERE task_id = ?').all(id);
|
|
62
|
+
return {
|
|
63
|
+
contents: [{
|
|
64
|
+
uri: uri.href,
|
|
65
|
+
text: JSON.stringify({
|
|
66
|
+
...task,
|
|
67
|
+
tags: task.tags ? JSON.parse(task.tags) : [],
|
|
68
|
+
total_time_ms: totalTime.total ?? 0,
|
|
69
|
+
recent_sessions: sessions,
|
|
70
|
+
dependencies: deps.map(d => d.dependency_id),
|
|
71
|
+
}, null, 2),
|
|
72
|
+
mimeType: 'application/json',
|
|
73
|
+
}],
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
package/dist/sse.js
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import { createServer } from 'http';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
2
3
|
import { getDb } from './db.js';
|
|
3
4
|
import { logActivity } from './helpers.js';
|
|
5
|
+
import { getConfig } from './config.js';
|
|
4
6
|
const SERVICE_ID = 'taskflow-mcp';
|
|
5
|
-
const MAX_PORT_ATTEMPTS = 10;
|
|
6
7
|
const PROBE_TIMEOUT_MS = 2000;
|
|
7
8
|
const clients = new Set();
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// ─── Event buffer for Last-Event-ID replay ──────────────────────────
|
|
10
|
+
const EVENT_BUFFER_SIZE = 200;
|
|
11
|
+
let eventIdCounter = 0;
|
|
12
|
+
const eventBuffer = [];
|
|
13
|
+
function bufferEvent(event, data) {
|
|
14
|
+
const id = ++eventIdCounter;
|
|
15
|
+
eventBuffer.push({ id, event, data });
|
|
16
|
+
if (eventBuffer.length > EVENT_BUFFER_SIZE) {
|
|
17
|
+
eventBuffer.shift();
|
|
13
18
|
}
|
|
14
|
-
|
|
15
|
-
if (process.env.TASKFLOW_SSE_PORT) {
|
|
16
|
-
return parseInt(process.env.TASKFLOW_SSE_PORT, 10);
|
|
17
|
-
}
|
|
18
|
-
return 3456;
|
|
19
|
+
return id;
|
|
19
20
|
}
|
|
20
|
-
const
|
|
21
|
+
const cfg = getConfig();
|
|
22
|
+
const PREFERRED_PORT = cfg.port;
|
|
21
23
|
/** The port that is actually serving SSE — either ours or an existing instance's */
|
|
22
24
|
let activePort = PREFERRED_PORT;
|
|
23
25
|
/** Probe a port to check if a TaskFlow service is already running there */
|
|
@@ -73,6 +75,14 @@ export async function startSSEServer() {
|
|
|
73
75
|
});
|
|
74
76
|
// Send initial connection event
|
|
75
77
|
res.write('event: connected\ndata: {}\n\n');
|
|
78
|
+
// Replay missed events if client sends Last-Event-ID
|
|
79
|
+
const lastEventId = req.headers['last-event-id'];
|
|
80
|
+
if (lastEventId && typeof lastEventId === 'string') {
|
|
81
|
+
const missed = eventBuffer.filter(e => e.id > Number(lastEventId));
|
|
82
|
+
for (const e of missed) {
|
|
83
|
+
res.write(`id: ${e.id}\nevent: ${e.event}\ndata: ${e.data}\n\n`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
76
86
|
clients.add(res);
|
|
77
87
|
req.on('close', () => {
|
|
78
88
|
clients.delete(res);
|
|
@@ -337,6 +347,77 @@ export async function startSSEServer() {
|
|
|
337
347
|
jsonResponse(res, 200, msg);
|
|
338
348
|
return;
|
|
339
349
|
}
|
|
350
|
+
// GET /api/terminal/:agentName/capture — capture terminal content via tmux
|
|
351
|
+
const captureMatch = req.url?.match(/^\/api\/terminal\/([^/]+)\/capture$/);
|
|
352
|
+
if (captureMatch && req.method === 'GET') {
|
|
353
|
+
const agentName = decodeURIComponent(captureMatch[1]);
|
|
354
|
+
const db = getDb();
|
|
355
|
+
const agent = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(agentName);
|
|
356
|
+
if (!agent) {
|
|
357
|
+
jsonResponse(res, 404, { error: `Agent "${agentName}" not found` });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!agent.tmux_pane) {
|
|
361
|
+
jsonResponse(res, 400, { error: `Agent "${agentName}" has no tmux pane` });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const output = execFileSync('tmux', ['capture-pane', '-p', '-t', agent.tmux_pane], { timeout: 5000 }).toString();
|
|
366
|
+
jsonResponse(res, 200, { agent: agentName, pane: agent.tmux_pane, content: output });
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
jsonResponse(res, 500, { error: `Failed to capture pane: ${err.message}` });
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// POST /api/terminal/:agentName/send-keys — inject raw keystrokes into agent's tmux pane
|
|
374
|
+
const sendKeysMatch = req.url?.match(/^\/api\/terminal\/([^/]+)\/send-keys$/);
|
|
375
|
+
if (sendKeysMatch && req.method === 'POST') {
|
|
376
|
+
const agentName = decodeURIComponent(sendKeysMatch[1]);
|
|
377
|
+
const db = getDb();
|
|
378
|
+
const agent = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(agentName);
|
|
379
|
+
if (!agent) {
|
|
380
|
+
jsonResponse(res, 404, { error: `Agent "${agentName}" not found` });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (!agent.tmux_pane) {
|
|
384
|
+
jsonResponse(res, 400, { error: `Agent "${agentName}" has no tmux pane` });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
let body;
|
|
388
|
+
try {
|
|
389
|
+
body = JSON.parse(await readBody(req));
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const keys = body.keys;
|
|
396
|
+
const enter = body.enter !== false; // default: send Enter after keys
|
|
397
|
+
if (typeof keys !== 'string') {
|
|
398
|
+
jsonResponse(res, 400, { error: 'keys must be a string' });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const args = ['send-keys', '-t', agent.tmux_pane, keys];
|
|
403
|
+
if (enter)
|
|
404
|
+
args.push('Enter');
|
|
405
|
+
execFileSync('tmux', args, { stdio: 'ignore', timeout: 5000 });
|
|
406
|
+
logActivity('terminal_send_keys', `Sent keys to ${agentName}: ${keys.slice(0, 50)}`, { entityType: 'agent' });
|
|
407
|
+
jsonResponse(res, 200, { agent: agentName, pane: agent.tmux_pane, keys, enter, sent: true });
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
jsonResponse(res, 500, { error: `Failed to send keys: ${err.message}` });
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// GET /api/terminal/agents — list agents with tmux panes for terminal interaction
|
|
415
|
+
if (req.url === '/api/terminal/agents' && req.method === 'GET') {
|
|
416
|
+
const db = getDb();
|
|
417
|
+
const agents = db.prepare("SELECT name, tmux_pane, status, pid FROM agent_registry WHERE tmux_pane IS NOT NULL ORDER BY connected_at DESC").all();
|
|
418
|
+
jsonResponse(res, 200, agents);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
340
421
|
res.writeHead(404);
|
|
341
422
|
res.end('Not Found');
|
|
342
423
|
});
|
|
@@ -344,8 +425,8 @@ export async function startSSEServer() {
|
|
|
344
425
|
await new Promise((resolve) => {
|
|
345
426
|
let attempt = 0;
|
|
346
427
|
function tryPort(port) {
|
|
347
|
-
if (attempt >=
|
|
348
|
-
console.error(`[SSE] failed to bind after ${
|
|
428
|
+
if (attempt >= cfg.maxPortAttempts) {
|
|
429
|
+
console.error(`[SSE] failed to bind after ${cfg.maxPortAttempts} attempts (ports ${PREFERRED_PORT}–${port - 1})`);
|
|
349
430
|
resolve();
|
|
350
431
|
return;
|
|
351
432
|
}
|
|
@@ -370,9 +451,21 @@ export async function startSSEServer() {
|
|
|
370
451
|
resolve();
|
|
371
452
|
}
|
|
372
453
|
});
|
|
373
|
-
server.listen(port,
|
|
454
|
+
server.listen(port, cfg.host, () => {
|
|
374
455
|
activePort = port;
|
|
375
456
|
markSSEActive();
|
|
457
|
+
// Heartbeat — keeps connections alive and detects stale clients
|
|
458
|
+
setInterval(() => {
|
|
459
|
+
const ping = `:ping ${Date.now()}\n\n`;
|
|
460
|
+
for (const client of clients) {
|
|
461
|
+
try {
|
|
462
|
+
client.write(ping);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
clients.delete(client);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}, 30_000);
|
|
376
469
|
if (port !== PREFERRED_PORT) {
|
|
377
470
|
console.log(`[SSE] listening on fallback port ${port} (preferred ${PREFERRED_PORT} was unavailable)`);
|
|
378
471
|
}
|
|
@@ -387,7 +480,9 @@ export async function startSSEServer() {
|
|
|
387
480
|
}
|
|
388
481
|
/** Broadcast directly to connected SSE clients in this process */
|
|
389
482
|
function broadcastLocal(event, data) {
|
|
390
|
-
const
|
|
483
|
+
const dataStr = JSON.stringify(data);
|
|
484
|
+
const id = bufferEvent(event, dataStr);
|
|
485
|
+
const message = `id: ${id}\nevent: ${event}\ndata: ${dataStr}\n\n`;
|
|
391
486
|
for (const client of clients) {
|
|
392
487
|
client.write(message);
|
|
393
488
|
}
|
|
@@ -412,8 +507,8 @@ export function broadcast(event, data) {
|
|
|
412
507
|
method: 'POST',
|
|
413
508
|
headers: { 'Content-Type': 'application/json' },
|
|
414
509
|
body,
|
|
415
|
-
}).catch(() => {
|
|
416
|
-
|
|
510
|
+
}).catch((err) => {
|
|
511
|
+
console.error(`[SSE] broadcast relay to port ${activePort} failed: ${err.message}`);
|
|
417
512
|
});
|
|
418
513
|
}
|
|
419
514
|
}
|