@dalmasonto/taskflow-mcp 1.0.10 → 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.
@@ -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;
@@ -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
- const existing = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(name);
71
- if (existing) {
72
- db.prepare('UPDATE agent_registry SET project_path = ?, pid = ?, tmux_pane = ?, status = ?, connected_at = ?, disconnected_at = NULL WHERE name = ?').run(projectPath, agentPid, tmuxPane, 'connected', ts, name);
73
- }
74
- else {
75
- db.prepare('INSERT INTO agent_registry (name, project_path, pid, tmux_pane, status, connected_at) VALUES (?, ?, ?, ?, ?, ?)').run(name, projectPath, agentPid, tmuxPane, 'connected', ts);
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' });
@@ -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
- const DEFAULT_DB_PATH = '~/.taskflow/taskflow.db';
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(process.env.TASKFLOW_DB_PATH || DEFAULT_DB_PATH);
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
- }, 30_000);
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,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerResources(server: McpServer): void;
@@ -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
- function resolvePort() {
9
- // CLI arg takes priority: --port 4000
10
- const portArgIdx = process.argv.indexOf('--port');
11
- if (portArgIdx !== -1 && process.argv[portArgIdx + 1]) {
12
- return parseInt(process.argv[portArgIdx + 1], 10);
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
- // Then env var
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 PREFERRED_PORT = resolvePort();
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 >= MAX_PORT_ATTEMPTS) {
348
- console.error(`[SSE] failed to bind after ${MAX_PORT_ATTEMPTS} attempts (ports ${PREFERRED_PORT}–${port - 1})`);
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, '0.0.0.0', () => {
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 message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
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
- // SSE server not running silently skip
510
+ }).catch((err) => {
511
+ console.error(`[SSE] broadcast relay to port ${activePort} failed: ${err.message}`);
417
512
  });
418
513
  }
419
514
  }