@dalmasonto/taskflow-mcp 1.0.6 → 1.0.7

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/README.md CHANGED
@@ -40,7 +40,10 @@ By default, Claude Code will prompt you to approve each MCP tool call. To allow
40
40
  "mcp__taskflow__*"
41
41
  ]
42
42
  },
43
- "enableAllProjectMcpServers": true
43
+ "enableAllProjectMcpServers": true,
44
+ "enabledMcpjsonServers": [
45
+ "taskflow"
46
+ ]
44
47
  }
45
48
  ```
46
49
 
@@ -112,6 +115,34 @@ The `get_agent_instructions` tool tells agents to:
112
115
  5. **Stay in sync** — create tasks for new work items to keep the tracker up to date
113
116
  6. **Read descriptions** — task descriptions contain implementation details and acceptance criteria
114
117
 
118
+ ## Agent Inbox — Remote Communication
119
+
120
+ The Agent Inbox lets agents ask questions that appear in the TaskFlow UI. Users can respond from any device (phone, browser, another machine), and the response is delivered back to the agent's terminal automatically.
121
+
122
+ ### How it works
123
+
124
+ 1. Agent calls `ask_user` with a question, context, and optional quick-tap choices
125
+ 2. Question appears instantly in the TaskFlow UI at `/inbox`
126
+ 3. User responds from the UI — response is injected into the agent's terminal via tmux
127
+ 4. Agent also asks in the terminal normally, so the user can answer from either place
128
+
129
+ ### Setup for auto-injection
130
+
131
+ For responses to be injected directly into the terminal, run Claude Code inside tmux:
132
+
133
+ ```bash
134
+ # Install tmux (one-time)
135
+ sudo apt-get install -y tmux
136
+
137
+ # Start a tmux session and run claude inside it
138
+ tmux new -s agent
139
+ claude
140
+ ```
141
+
142
+ Without tmux, the inbox still works — agents can use `check_response` to poll for answers, or the user can dismiss questions answered in the terminal.
143
+
144
+ See [Terminal Injection Setup](../docs/agent-inbox-terminal-injection.md) for full details, multiple agent setup, and cleanup instructions.
145
+
115
146
  ## Available Tools
116
147
 
117
148
  ### Agent
@@ -168,6 +199,12 @@ The `get_agent_instructions` tool tells agents to:
168
199
  | `mark_all_notifications_read` | Mark all as read |
169
200
  | `clear_notifications` | Delete all notifications |
170
201
 
202
+ ### Agent Inbox
203
+ | Tool | Description |
204
+ |------|-------------|
205
+ | `ask_user` | Post a question to the Agent Inbox for remote response. Returns immediately with message ID |
206
+ | `check_response` | Check if the user has responded to a previously posted question |
207
+
171
208
  ### Settings
172
209
  | Tool | Description |
173
210
  |------|-------------|
@@ -0,0 +1,23 @@
1
+ interface AgentRow {
2
+ id: number;
3
+ name: string;
4
+ project_path: string;
5
+ pid: number;
6
+ tmux_pane: string | null;
7
+ status: string;
8
+ connected_at: string;
9
+ disconnected_at: string | null;
10
+ }
11
+ /** Register an agent. Returns the assigned name. */
12
+ export declare function registerAgent(options?: {
13
+ customName?: string;
14
+ }): string;
15
+ /** Mark an agent as disconnected */
16
+ export declare function unregisterAgent(name: string): void;
17
+ /** Check all registered agents and mark dead ones as disconnected */
18
+ export declare function checkAgentLiveness(): void;
19
+ /** Get a registered agent by name */
20
+ export declare function getAgent(name: string): AgentRow | undefined;
21
+ /** List all agents, optionally filtered by status */
22
+ export declare function listAgents(status?: string): AgentRow[];
23
+ export {};
@@ -0,0 +1,107 @@
1
+ import { execSync } from 'child_process';
2
+ import { getDb } from './db.js';
3
+ import { broadcast } from './sse.js';
4
+ import { logActivity } from './helpers.js';
5
+ /** Check if a process is still alive */
6
+ function isAlive(pid) {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ /** Detect the tmux pane for a given PID */
16
+ function detectTmuxPane(pid) {
17
+ try {
18
+ const ptsPath = execSync(`readlink /proc/${pid}/fd/0`).toString().trim();
19
+ const panes = execSync('tmux list-panes -a -F "#{pane_id} #{pane_tty}"').toString().trim().split('\n');
20
+ for (const line of panes) {
21
+ const [paneId, paneTty] = line.split(' ');
22
+ if (paneTty === ptsPath)
23
+ return paneId;
24
+ }
25
+ }
26
+ catch { /* tmux not available */ }
27
+ return null;
28
+ }
29
+ /** Generate a unique agent name from the project folder, auto-suffixing on collision */
30
+ function generateName(folderName) {
31
+ const db = getDb();
32
+ const existing = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(folderName);
33
+ if (!existing || !isAlive(existing.pid)) {
34
+ return folderName;
35
+ }
36
+ for (let i = 2; i < 100; i++) {
37
+ const candidate = `${folderName}:${i}`;
38
+ const row = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(candidate);
39
+ if (!row || !isAlive(row.pid))
40
+ return candidate;
41
+ }
42
+ return `${folderName}:${Date.now()}`;
43
+ }
44
+ /** Register an agent. Returns the assigned name. */
45
+ export function registerAgent(options) {
46
+ const db = getDb();
47
+ const agentPid = process.ppid;
48
+ const projectPath = process.cwd();
49
+ const folderName = projectPath.split('/').pop() || 'unknown';
50
+ // Check if this PID already has a registration — reuse it
51
+ const existingByPid = db.prepare('SELECT * FROM agent_registry WHERE pid = ? AND status = ?').get(agentPid, 'connected');
52
+ if (existingByPid) {
53
+ // Update tmux pane in case it changed, but keep the same name
54
+ const tmuxPane = detectTmuxPane(agentPid);
55
+ if (tmuxPane !== existingByPid.tmux_pane) {
56
+ db.prepare('UPDATE agent_registry SET tmux_pane = ? WHERE id = ?').run(tmuxPane, existingByPid.id);
57
+ }
58
+ return existingByPid.name;
59
+ }
60
+ // Clean up dead agents first to free up names
61
+ checkAgentLiveness();
62
+ const name = options?.customName || generateName(folderName);
63
+ const tmuxPane = detectTmuxPane(agentPid);
64
+ const ts = new Date().toISOString();
65
+ const existing = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(name);
66
+ if (existing) {
67
+ 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);
68
+ }
69
+ else {
70
+ db.prepare('INSERT INTO agent_registry (name, project_path, pid, tmux_pane, status, connected_at) VALUES (?, ?, ?, ?, ?, ?)').run(name, projectPath, agentPid, tmuxPane, 'connected', ts);
71
+ }
72
+ const row = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(name);
73
+ broadcast('agent_connected', { entity: 'agent', action: 'agent_connected', payload: row });
74
+ logActivity('agent_connected', `Agent "${name}" connected`, { entityType: 'agent' });
75
+ return name;
76
+ }
77
+ /** Mark an agent as disconnected */
78
+ export function unregisterAgent(name) {
79
+ const db = getDb();
80
+ const ts = new Date().toISOString();
81
+ db.prepare("UPDATE agent_registry SET status = 'disconnected', disconnected_at = ? WHERE name = ?").run(ts, name);
82
+ const row = db.prepare('SELECT * FROM agent_registry WHERE name = ?').get(name);
83
+ broadcast('agent_disconnected', { entity: 'agent', action: 'agent_disconnected', payload: row });
84
+ logActivity('agent_disconnected', `Agent "${name}" disconnected`, { entityType: 'agent' });
85
+ }
86
+ /** Check all registered agents and mark dead ones as disconnected */
87
+ export function checkAgentLiveness() {
88
+ const db = getDb();
89
+ const liveAgents = db.prepare("SELECT * FROM agent_registry WHERE status = 'connected'").all();
90
+ for (const agent of liveAgents) {
91
+ if (!isAlive(agent.pid)) {
92
+ unregisterAgent(agent.name);
93
+ }
94
+ }
95
+ }
96
+ /** Get a registered agent by name */
97
+ export function getAgent(name) {
98
+ return getDb().prepare('SELECT * FROM agent_registry WHERE name = ?').get(name);
99
+ }
100
+ /** List all agents, optionally filtered by status */
101
+ export function listAgents(status) {
102
+ const db = getDb();
103
+ if (status) {
104
+ return db.prepare('SELECT * FROM agent_registry WHERE status = ? ORDER BY connected_at DESC').all(status);
105
+ }
106
+ return db.prepare('SELECT * FROM agent_registry ORDER BY connected_at DESC').all();
107
+ }
package/dist/db.js CHANGED
@@ -91,6 +91,33 @@ function initSchema(db) {
91
91
  value TEXT NOT NULL
92
92
  );
93
93
 
94
+ CREATE TABLE IF NOT EXISTS agent_messages (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
97
+ question TEXT NOT NULL,
98
+ context TEXT,
99
+ choices TEXT,
100
+ response TEXT,
101
+ agent_pid INTEGER,
102
+ delivered INTEGER,
103
+ sender_name TEXT NOT NULL DEFAULT 'unknown',
104
+ recipient_name TEXT NOT NULL DEFAULT 'user',
105
+ status TEXT NOT NULL DEFAULT 'pending',
106
+ created_at TEXT NOT NULL,
107
+ answered_at TEXT
108
+ );
109
+
110
+ CREATE TABLE IF NOT EXISTS agent_registry (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ name TEXT NOT NULL UNIQUE,
113
+ project_path TEXT NOT NULL,
114
+ pid INTEGER NOT NULL,
115
+ tmux_pane TEXT,
116
+ status TEXT NOT NULL DEFAULT 'connected',
117
+ connected_at TEXT NOT NULL,
118
+ disconnected_at TEXT
119
+ );
120
+
94
121
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
95
122
  CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
96
123
  CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
@@ -99,7 +126,59 @@ function initSchema(db) {
99
126
  CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action);
100
127
  CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read);
101
128
  CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at);
129
+ CREATE INDEX IF NOT EXISTS idx_agent_messages_status ON agent_messages(status);
130
+ CREATE INDEX IF NOT EXISTS idx_agent_messages_project_id ON agent_messages(project_id);
131
+ CREATE INDEX IF NOT EXISTS idx_agent_registry_status ON agent_registry(status);
132
+ CREATE INDEX IF NOT EXISTS idx_agent_registry_name ON agent_registry(name);
102
133
  `);
134
+ // Migrations — add columns that may be missing on existing databases
135
+ const cols = db.prepare("PRAGMA table_info(agent_messages)").all();
136
+ const colNames = new Set(cols.map(c => c.name));
137
+ if (!colNames.has('agent_pid')) {
138
+ db.exec('ALTER TABLE agent_messages ADD COLUMN agent_pid INTEGER');
139
+ }
140
+ if (!colNames.has('delivered')) {
141
+ db.exec('ALTER TABLE agent_messages ADD COLUMN delivered INTEGER');
142
+ }
143
+ if (!colNames.has('sender_name')) {
144
+ db.exec("ALTER TABLE agent_messages ADD COLUMN sender_name TEXT NOT NULL DEFAULT 'unknown'");
145
+ }
146
+ if (!colNames.has('recipient_name')) {
147
+ db.exec("ALTER TABLE agent_messages ADD COLUMN recipient_name TEXT NOT NULL DEFAULT 'user'");
148
+ }
149
+ // Migration: make agent_messages.project_id nullable if it was NOT NULL
150
+ try {
151
+ const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'agent_messages'").get();
152
+ if (tableInfo?.sql?.includes('project_id INTEGER NOT NULL')) {
153
+ db.exec(`
154
+ CREATE TABLE agent_messages_new (
155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
157
+ question TEXT NOT NULL,
158
+ context TEXT,
159
+ choices TEXT,
160
+ response TEXT,
161
+ agent_pid INTEGER,
162
+ delivered INTEGER,
163
+ sender_name TEXT NOT NULL DEFAULT 'unknown',
164
+ recipient_name TEXT NOT NULL DEFAULT 'user',
165
+ status TEXT NOT NULL DEFAULT 'pending',
166
+ created_at TEXT NOT NULL,
167
+ answered_at TEXT
168
+ );
169
+ INSERT INTO agent_messages_new SELECT
170
+ id, project_id, question, context, choices, response, agent_pid, delivered,
171
+ COALESCE(sender_name, 'unknown'), COALESCE(recipient_name, 'user'),
172
+ status, created_at, answered_at
173
+ FROM agent_messages;
174
+ DROP TABLE agent_messages;
175
+ ALTER TABLE agent_messages_new RENAME TO agent_messages;
176
+ CREATE INDEX IF NOT EXISTS idx_agent_messages_status ON agent_messages(status);
177
+ CREATE INDEX IF NOT EXISTS idx_agent_messages_project_id ON agent_messages(project_id);
178
+ `);
179
+ }
180
+ }
181
+ catch { /* table may not exist yet */ }
103
182
  }
104
183
  export function closeDb() {
105
184
  if (db) {
package/dist/index.js CHANGED
@@ -33,6 +33,14 @@ function cleanupOrphanedSessions() {
33
33
  cleanupOrphanedSessions();
34
34
  // Always start the HTTP/SSE server (probes for existing instances, finds fallback port)
35
35
  await startSSEServer();
36
+ // Liveness checker — periodically check registered agents and mark dead ones
37
+ setInterval(async () => {
38
+ try {
39
+ const { checkAgentLiveness } = await import('./agent-registry.js');
40
+ checkAgentLiveness();
41
+ }
42
+ catch { /* ignore */ }
43
+ }, 30_000);
36
44
  // Only start MCP stdio transport when not in http-only mode
37
45
  if (!httpOnly) {
38
46
  const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
@@ -45,6 +53,7 @@ if (!httpOnly) {
45
53
  const { registerNotificationTools } = await import('./tools/notifications.js');
46
54
  const { registerSettingsTools } = await import('./tools/settings.js');
47
55
  const { registerAgentTools } = await import('./tools/agent.js');
56
+ const { registerAgentInboxTools } = await import('./tools/agent-inbox.js');
48
57
  const server = new McpServer({
49
58
  name: 'taskflow',
50
59
  version: '1.0.0',
@@ -57,6 +66,82 @@ if (!httpOnly) {
57
66
  registerActivityTools(server);
58
67
  registerNotificationTools(server);
59
68
  registerSettingsTools(server);
69
+ registerAgentInboxTools(server);
60
70
  const transport = new StdioServerTransport();
61
71
  await server.connect(transport);
72
+ const { registerAgent, unregisterAgent } = await import('./agent-registry.js');
73
+ // Auto-register this agent
74
+ const agentName = registerAgent();
75
+ console.error(`[agent] registered as "${agentName}"`);
76
+ // Graceful shutdown — mark agent as disconnected
77
+ const cleanup = () => { try {
78
+ unregisterAgent(agentName);
79
+ }
80
+ catch { } process.exit(0); };
81
+ process.on('SIGINT', cleanup);
82
+ process.on('SIGTERM', cleanup);
83
+ // Background poller: deliver messages to this agent's terminal via tmux
84
+ const POLL_INTERVAL = 3000;
85
+ const agentPid = process.ppid;
86
+ let tmuxTarget = null;
87
+ try {
88
+ const { execSync: exec } = await import('child_process');
89
+ const ptsPath = exec(`readlink /proc/${agentPid}/fd/0`).toString().trim();
90
+ const panes = exec('tmux list-panes -a -F "#{pane_id} #{pane_tty}"').toString().trim().split('\n');
91
+ for (const line of panes) {
92
+ const [paneId, paneTty] = line.split(' ');
93
+ if (paneTty === ptsPath) {
94
+ tmuxTarget = paneId;
95
+ break;
96
+ }
97
+ }
98
+ if (tmuxTarget)
99
+ console.error(`[inject] tmux pane ${tmuxTarget} for agent "${agentName}"`);
100
+ else
101
+ console.error('[inject] agent not in tmux — terminal injection disabled');
102
+ }
103
+ catch {
104
+ console.error('[inject] tmux not available');
105
+ }
106
+ if (tmuxTarget) {
107
+ const { execSync: exec } = await import('child_process');
108
+ const target = tmuxTarget;
109
+ setInterval(() => {
110
+ try {
111
+ const db = getDb();
112
+ // Check for messages addressed to this agent (from user or other agents)
113
+ // AND for answered questions this agent sent (inbox responses)
114
+ // Check by agent name AND by agent_pid (backward compat with old messages)
115
+ const incoming = db.prepare(`SELECT * FROM agent_messages WHERE delivered IS NULL AND (
116
+ (recipient_name = ? AND status = 'pending') OR
117
+ (sender_name = ? AND recipient_name = 'user' AND status = 'answered') OR
118
+ (agent_pid = ? AND recipient_name = 'user' AND status = 'answered')
119
+ )`).all(agentName, agentName, agentPid);
120
+ for (const msg of incoming) {
121
+ db.prepare('UPDATE agent_messages SET delivered = 1 WHERE id = ?').run(msg.id);
122
+ let text;
123
+ if (msg.recipient_name === agentName && msg.sender_name === 'user') {
124
+ text = `[Message from User]: ${msg.question}`;
125
+ }
126
+ else if (msg.recipient_name === agentName && msg.sender_name !== 'user') {
127
+ text = `[Message from ${msg.sender_name}]: ${msg.question}`;
128
+ }
129
+ else if (msg.status === 'answered' && msg.response) {
130
+ text = `[Inbox Response] to "${msg.question.slice(0, 60)}": ${msg.response}`;
131
+ }
132
+ else {
133
+ continue;
134
+ }
135
+ try {
136
+ exec(`tmux send-keys -t ${target} ${JSON.stringify(text)} Enter`, { stdio: 'ignore', timeout: 5000 });
137
+ console.error(`[inject] delivered message ${msg.id} to tmux pane ${target}`);
138
+ }
139
+ catch (err) {
140
+ console.error(`[inject] tmux send-keys failed for message ${msg.id}:`, err);
141
+ }
142
+ }
143
+ }
144
+ catch { /* ignore */ }
145
+ }, POLL_INTERVAL);
146
+ }
62
147
  }
package/dist/sse.js CHANGED
@@ -86,7 +86,9 @@ export async function startSSEServer() {
86
86
  const sessions = db.prepare('SELECT * FROM sessions').all();
87
87
  const settings = db.prepare('SELECT * FROM settings').all();
88
88
  const activityLogs = db.prepare('SELECT * FROM activity_logs ORDER BY created_at DESC LIMIT 200').all();
89
- jsonResponse(res, 200, { tasks, projects, sessions, settings, activityLogs });
89
+ const agentMessages = db.prepare('SELECT * FROM agent_messages ORDER BY created_at DESC LIMIT 100').all();
90
+ const agentRegistry = db.prepare('SELECT * FROM agent_registry ORDER BY connected_at DESC').all();
91
+ jsonResponse(res, 200, { tasks, projects, sessions, settings, activityLogs, agentMessages, agentRegistry });
90
92
  return;
91
93
  }
92
94
  // ─── Mutation endpoints ───────────────────────────────────────────
@@ -97,6 +99,8 @@ export async function startSSEServer() {
97
99
  db.exec('DELETE FROM projects');
98
100
  db.exec('DELETE FROM notifications');
99
101
  db.exec('DELETE FROM activity_logs');
102
+ db.exec('DELETE FROM agent_messages');
103
+ db.exec('DELETE FROM agent_registry');
100
104
  logActivity('data_cleared', 'All data cleared via UI', { entityType: 'system' });
101
105
  broadcast('data_cleared', { entity: 'system', action: 'data_cleared', payload: {} });
102
106
  jsonResponse(res, 200, { cleared: true });
@@ -262,6 +266,75 @@ export async function startSSEServer() {
262
266
  jsonResponse(res, 200, { relayed: true });
263
267
  return;
264
268
  }
269
+ // POST /api/agent-messages/:id/respond — user responds to an agent question
270
+ const agentRespondMatch = req.url?.match(/^\/api\/agent-messages\/(\d+)\/respond$/);
271
+ if (agentRespondMatch && req.method === 'POST') {
272
+ const db = getDb();
273
+ const id = Number(agentRespondMatch[1]);
274
+ const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
275
+ if (!message) {
276
+ jsonResponse(res, 404, { error: 'Message not found' });
277
+ return;
278
+ }
279
+ if (message.status === 'answered') {
280
+ jsonResponse(res, 400, { error: 'Already answered' });
281
+ return;
282
+ }
283
+ const body = JSON.parse(await readBody(req));
284
+ const response = body.response;
285
+ if (!response) {
286
+ jsonResponse(res, 400, { error: 'Response is required' });
287
+ return;
288
+ }
289
+ const ts = new Date().toISOString();
290
+ db.prepare('UPDATE agent_messages SET response = ?, status = ?, answered_at = ? WHERE id = ?')
291
+ .run(response, 'answered', ts, id);
292
+ const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
293
+ broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
294
+ logActivity('agent_question_answered', `Responded to: ${message.question}`, { entityType: 'agent_message', entityId: id });
295
+ jsonResponse(res, 200, updated);
296
+ return;
297
+ }
298
+ // POST /api/agent-messages/:id/dismiss — dismiss without responding (answered in terminal)
299
+ const agentDismissMatch = req.url?.match(/^\/api\/agent-messages\/(\d+)\/dismiss$/);
300
+ if (agentDismissMatch && req.method === 'POST') {
301
+ const db = getDb();
302
+ const id = Number(agentDismissMatch[1]);
303
+ const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
304
+ if (!message) {
305
+ jsonResponse(res, 404, { error: 'Message not found' });
306
+ return;
307
+ }
308
+ if (message.status !== 'pending') {
309
+ jsonResponse(res, 400, { error: 'Message is not pending' });
310
+ return;
311
+ }
312
+ const ts = new Date().toISOString();
313
+ db.prepare('UPDATE agent_messages SET status = ?, answered_at = ? WHERE id = ?')
314
+ .run('dismissed', ts, id);
315
+ const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
316
+ broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
317
+ jsonResponse(res, 200, updated);
318
+ return;
319
+ }
320
+ // POST /api/agent-messages/send — user sends a message to an agent
321
+ if (req.url === '/api/agent-messages/send' && req.method === 'POST') {
322
+ const db = getDb();
323
+ const body = JSON.parse(await readBody(req));
324
+ const { recipient, message: msgText, projectId } = body;
325
+ if (!recipient || !msgText) {
326
+ jsonResponse(res, 400, { error: 'recipient and message are required' });
327
+ return;
328
+ }
329
+ const ts = new Date().toISOString();
330
+ const result = db.prepare(`INSERT INTO agent_messages (project_id, question, sender_name, recipient_name, status, created_at)
331
+ VALUES (?, ?, 'user', ?, 'pending', ?)`).run(projectId ?? null, msgText, recipient, ts);
332
+ const id = result.lastInsertRowid;
333
+ const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
334
+ broadcast('agent_question', { entity: 'agent_message', action: 'agent_question', payload: msg });
335
+ jsonResponse(res, 200, msg);
336
+ return;
337
+ }
265
338
  res.writeHead(404);
266
339
  res.end('Not Found');
267
340
  });
@@ -18,6 +18,7 @@ export declare function clearActivityLog(): Promise<{
18
18
  export declare function logDebug(params: {
19
19
  message: string;
20
20
  task_id?: number;
21
+ project_id?: number;
21
22
  detail?: string;
22
23
  }): Promise<{
23
24
  content: {
@@ -34,8 +34,11 @@ export async function clearActivityLog() {
34
34
  export async function logDebug(params) {
35
35
  const db = getDb();
36
36
  const now = new Date().toISOString();
37
+ // task_id takes priority; fall back to project_id
38
+ const entityType = params.task_id ? 'task' : params.project_id ? 'project' : null;
39
+ const entityId = params.task_id ?? params.project_id ?? null;
37
40
  const result = db.prepare(`INSERT INTO activity_logs (action, title, detail, entity_type, entity_id, created_at)
38
- VALUES (?, ?, ?, ?, ?, ?)`).run('debug_log', params.message, params.detail ?? null, params.task_id ? 'task' : null, params.task_id ?? null, now);
41
+ VALUES (?, ?, ?, ?, ?, ?)`).run('debug_log', params.message, params.detail ?? null, entityType, entityId, now);
39
42
  const entry = db.prepare('SELECT * FROM activity_logs WHERE id = ?').get(result.lastInsertRowid);
40
43
  broadcastChange('activity', 'activity_logged', entry);
41
44
  return successResponse({ id: result.lastInsertRowid, message: params.message });
@@ -48,9 +51,10 @@ export function registerActivityTools(server) {
48
51
  entity_type: z.string().optional(),
49
52
  }, async (params) => getActivityLog(params));
50
53
  server.tool('clear_activity_log', 'Delete all activity log entries. Use with caution — this is irreversible.', {}, async () => clearActivityLog());
51
- server.tool('log_debug', 'Log a debug entry to the activity log. Use this while debugging to record what you are investigating, what you tried, what you found, and your reasoning. Optionally link to a task. These entries appear in the Activity Pulse in the UI.', {
54
+ server.tool('log_debug', 'Log a debug entry to the activity log. Use this to record your work process what you investigated, commands you ran, decisions you made, and findings. Entries appear in the Activity Pulse and on the project page. Link to a task (task_id) or project (project_id) for context.', {
52
55
  message: z.string().describe('Short summary of what you are doing or found'),
53
- detail: z.string().optional().describe('Longer explanation — stack traces, error messages, hypotheses, what you tried'),
56
+ detail: z.string().optional().describe('Longer explanation — stack traces, error messages, hypotheses, commands run, what you tried'),
54
57
  task_id: z.number().optional().describe('Link this debug log to a specific task'),
58
+ project_id: z.number().optional().describe('Link this debug log to a project (used when no specific task applies)'),
55
59
  }, async (params) => logDebug(params));
56
60
  }
@@ -0,0 +1,5 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /** The name assigned to this agent after registration */
3
+ declare let myAgentName: string | null;
4
+ export { myAgentName };
5
+ export declare function registerAgentInboxTools(server: McpServer): void;
@@ -0,0 +1,103 @@
1
+ import { z } from 'zod';
2
+ import { getDb } from '../db.js';
3
+ import { logActivity, errorResponse, successResponse, now, broadcastChange } from '../helpers.js';
4
+ import { registerAgent as doRegister, getAgent, listAgents } from '../agent-registry.js';
5
+ /** The name assigned to this agent after registration */
6
+ let myAgentName = null;
7
+ /** Get or auto-register the agent name */
8
+ function ensureRegistered() {
9
+ if (!myAgentName) {
10
+ myAgentName = doRegister();
11
+ }
12
+ return myAgentName;
13
+ }
14
+ export { myAgentName };
15
+ export function registerAgentInboxTools(server) {
16
+ server.tool('register_agent', 'Register this agent with a custom name. Optional — agents auto-register on startup using the project folder name. Call this only if you want a specific name.', {
17
+ name: z.string().optional().describe('Custom agent name. If omitted, uses the project folder name.'),
18
+ }, async (params) => {
19
+ myAgentName = doRegister({ customName: params.name });
20
+ return successResponse({ name: myAgentName, message: `Registered as "${myAgentName}"` });
21
+ });
22
+ server.tool('ask_user', 'Post a question to the TaskFlow Agent Inbox for the user to answer remotely. Returns immediately with the message ID. The question appears in the Agent Inbox UI with full context and optional quick-tap choices. After posting, use check_response to retrieve the user\'s answer. Always tell the user you posted a question so they know to check the inbox.', {
23
+ project_id: z.number().describe('Project ID to attach the question to'),
24
+ question: z.string().describe('The question to ask the user'),
25
+ context: z.string().optional().describe('Markdown context — proposals, trade-offs, code snippets shown before the question'),
26
+ choices: z.array(z.string()).optional().describe('Optional quick-tap choices, e.g. ["Yes", "No", "Skip"]'),
27
+ }, async (params) => {
28
+ const db = getDb();
29
+ const project = db.prepare('SELECT id FROM projects WHERE id = ?').get(params.project_id);
30
+ if (!project)
31
+ return errorResponse(`Project ${params.project_id} not found`, 'NOT_FOUND');
32
+ const senderName = ensureRegistered();
33
+ const ts = now();
34
+ const result = db.prepare(`INSERT INTO agent_messages (project_id, question, context, choices, sender_name, recipient_name, agent_pid, status, created_at)
35
+ VALUES (?, ?, ?, ?, ?, 'user', ?, 'pending', ?)`).run(params.project_id, params.question, params.context ?? null, params.choices ? JSON.stringify(params.choices) : null, senderName, process.ppid, ts);
36
+ const id = result.lastInsertRowid;
37
+ const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
38
+ broadcastChange('agent_message', 'agent_question', message);
39
+ logActivity('agent_question', params.question, { entityType: 'agent_message', entityId: id });
40
+ return successResponse({
41
+ id,
42
+ status: 'pending',
43
+ sender: senderName,
44
+ message: `Question posted to Agent Inbox (id: ${id}). Use check_response(${id}) to retrieve the user's answer.`,
45
+ });
46
+ });
47
+ server.tool('check_response', 'Check if the user has responded to a previously posted agent question. Returns the response if answered, or status "pending" if still waiting.', {
48
+ message_id: z.number().describe('The agent message ID returned by ask_user'),
49
+ }, async (params) => {
50
+ const db = getDb();
51
+ const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(params.message_id);
52
+ if (!message)
53
+ return errorResponse(`Message ${params.message_id} not found`, 'NOT_FOUND');
54
+ if (message.status === 'answered') {
55
+ return successResponse({
56
+ id: message.id, status: 'answered', response: message.response,
57
+ question: message.question, answered_at: message.answered_at,
58
+ });
59
+ }
60
+ return successResponse({
61
+ id: message.id, status: 'pending', question: message.question,
62
+ message: 'User has not responded yet. Try again later or continue with other work.',
63
+ });
64
+ });
65
+ server.tool('send_to_agent', 'Send a message to another agent by name. Returns immediately. The recipient agent will receive the message in their terminal (if running in tmux).', {
66
+ recipient: z.string().describe('Name of the target agent (e.g. "backend", "task_flow:2")'),
67
+ message: z.string().describe('The message to send'),
68
+ context: z.string().optional().describe('Optional markdown context'),
69
+ }, async (params) => {
70
+ const db = getDb();
71
+ const senderName = ensureRegistered();
72
+ const recipient = getAgent(params.recipient);
73
+ if (!recipient)
74
+ return errorResponse(`Agent "${params.recipient}" not found`, 'NOT_FOUND');
75
+ const ts = now();
76
+ const result = db.prepare(`INSERT INTO agent_messages (project_id, question, context, sender_name, recipient_name, status, created_at)
77
+ VALUES (NULL, ?, ?, ?, ?, 'pending', ?)`).run(params.message, params.context ?? null, senderName, params.recipient, ts);
78
+ const id = result.lastInsertRowid;
79
+ const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
80
+ broadcastChange('agent_message', 'agent_question', msg);
81
+ return successResponse({ id, sender: senderName, recipient: params.recipient, status: 'pending' });
82
+ });
83
+ server.tool('check_messages', 'Check for incoming messages from users or other agents addressed to this agent.', {}, async () => {
84
+ const db = getDb();
85
+ const name = ensureRegistered();
86
+ const messages = db.prepare(`SELECT * FROM agent_messages WHERE recipient_name = ? AND status = 'pending' ORDER BY created_at ASC`).all(name);
87
+ return successResponse({
88
+ agent: name,
89
+ count: messages.length,
90
+ messages: messages.map(m => ({
91
+ id: m.id, sender: m.sender_name, question: m.question,
92
+ context: m.context, choices: m.choices ? JSON.parse(m.choices) : null,
93
+ created_at: m.created_at,
94
+ })),
95
+ });
96
+ });
97
+ server.tool('list_agents', 'List registered agents with their status, project path, and connection info.', {
98
+ status: z.enum(['connected', 'disconnected']).optional().describe('Filter by status. Omit for all agents.'),
99
+ }, async (params) => {
100
+ const agents = listAgents(params.status);
101
+ return successResponse(agents);
102
+ });
103
+ }
@@ -12,8 +12,8 @@ export async function getAgentInstructions() {
12
12
  const instructions = {
13
13
  role: 'TaskFlow — local-first task & time tracker with MCP integration.',
14
14
  startup: [
15
- 'search_projects to find the current project (try name variants; confirm with user if ambiguous)',
16
- 'list_tasks status="in_progress" and status="blocked"',
15
+ 'Derive the project name from the **folder name** of the current working directory (e.g. `/home/user/projects/my-app` search for "my-app"). Use search_projects with that name. If 2–3 results match, **ask the user** which project to use — never guess.',
16
+ 'list_tasks status="in_progress" and status="blocked" for the confirmed project.',
17
17
  'list_notifications unread_only=true',
18
18
  ],
19
19
  state: {
@@ -24,26 +24,43 @@ export async function getAgentInstructions() {
24
24
  unread: unreadNotifs,
25
25
  },
26
26
  rules: [
27
+ // Project discovery
28
+ 'The project name should be derived from the working directory folder name. Always search_projects first. If no match, create the project using create_project with the folder name. If multiple matches, present them to the user and ask which one to use.',
27
29
  // Task tracking
28
30
  'Proactively create tasks for ALL substantial work — features, bugs, refactors, AND debugging/investigation. Debugging is real work: create a task for it (e.g. "Debug: SSE connection dropping"), start a timer, and track it the same way you would a feature. search_tasks first to avoid duplicates. Link tasks to the confirmed project.',
29
31
  'Timer lifecycle: start_timer → work → stop_timer(final_status). Use "done"/"partial_done"/"blocked". pause_timer when waiting for input. This applies equally to debugging tasks — start a timer before investigating, stop it when resolved or blocked.',
30
32
  'MUST stop_timer with "done" when work is complete. Never leave finished tasks in "in_progress" or "paused". This includes debugging tasks — when the bug is fixed or the investigation concludes, stop the timer.',
33
+ // Bugs & post-build debugging
34
+ 'When a user reports a bug or you discover one while testing/building: FIRST search_tasks for an existing related task. If found (even if "done"), move it back to in_progress (update_task_status) — this reopens the task and auto-starts a timer. Then log_debug on that task to document the new bug. If no related task exists, create a new one (e.g. "Bug: sidebar not rendering in light mode") with tag "bug", start the timer, and begin debugging.',
35
+ 'This also applies to post-build issues: after running a build/test and seeing failures, do NOT silently fix them. Open or reopen a task first, log what failed, then fix. The task becomes the paper trail — the user and future agents can see what broke, what was tried, and how it was resolved.',
31
36
  // Dependencies
32
37
  'Check task dependencies before starting. If any dep is incomplete, set task to "blocked".',
33
38
  'After completing a task, check if blocked tasks depending on it can be unblocked.',
34
39
  // Prioritization
35
40
  'When user is unsure what to work on: list_tasks priority="critical"/"high" status="not_started".',
36
- // Logging
37
- 'ALWAYS log your entire debugging process using log_debug with task_id. Log every stage: issue identification, hypothesis, files read/edited, commands run, errors encountered, fixes attempted, and resolution. This creates a visible trail in the Activity Pulse so users can follow your reasoning in real-time.',
38
- 'log_debug early and often not just at the end. Log when you start investigating, when you find a clue, when you edit a file, and when the fix lands. Use Markdown: headings for stages, code blocks for errors/paths, bold for key findings.',
41
+ // Transparency — CRITICAL
42
+ 'ALWAYS tell the user what commands you are running and why. Before executing a shell command, state: "Running: `<command>`". After significant actions (file edits, installs, config changes), summarize what changed. The user must be able to reconstruct what happened from your messages alone they should never wonder "what did the agent do?".',
43
+ 'When starting work, briefly state the approach: what you plan to do, which files you expect to touch, and what commands you will run. This gives the user a chance to course-correct before you act.',
44
+ // Debug logging — comprehensive
45
+ 'ALWAYS log your work using log_debug. Log with a task_id when working on a specific task, or with project_id for project-level observations. Use log_debug as a running journal — it shows up in the Activity Pulse and on the project page so the user (and future agents) can follow your reasoning.',
46
+ 'What to log: **every stage** of your process. When you start investigating, when you form a hypothesis, when you read/edit a file, the exact commands you run (with output snippets), errors encountered, fixes attempted, and the resolution. Log the **path you took**, not just the destination.',
47
+ 'Log early and often — not just at the end. A debug log entry per significant step (e.g. "Read `sse.ts:57` — found the healthz route exists but is unreachable because..."). Use Markdown: `## headings` for stages, `` `code blocks` `` for errors/paths/commands, **bold** for key findings.',
48
+ 'For project-level notes (architecture decisions, setup gotchas, "how to run the app"), use log_debug with project_id instead of task_id. These appear on the project page and serve as living documentation.',
39
49
  // Formatting
40
50
  'Use Markdown in descriptions — headings, bullets, code blocks, bold. The UI renders it.',
51
+ // Agent Inbox — remote communication
52
+ 'When you need user input (choices, confirmations, clarifications), ALWAYS do BOTH: (1) ask the question normally in the terminal conversation, AND (2) call ask_user to post it to the Agent Inbox. This lets the user respond from either the terminal or the TaskFlow UI remotely. If the user explicitly tells you not to use the inbox (e.g. "don\'t post to inbox"), skip the ask_user call.',
53
+ 'After calling ask_user, continue your work if possible. The user may respond from the UI, and the response will be delivered to your terminal automatically (if running in tmux). You can also call check_response to poll for the answer.',
54
+ // Agent Inbox — formatting
55
+ 'When calling ask_user, write **proper titles** for the question field — clear, concise, well-formed sentences (not random fragments or debug-style text). The question is the headline the user sees first.',
56
+ 'The `context` field in ask_user is rendered as Markdown in the UI. Format it well: use `## headings` to organize sections, `**bold**` for key terms, bullet lists for options/trade-offs, and `\\`code\\`` for file paths or commands. Use real newlines (not literal \\\\n). The context should read like a well-written message, not raw debug output.',
41
57
  ],
42
58
  workflow: 'not_started → in_progress (start_timer) → paused (pause_timer) → done/partial_done/blocked (stop_timer)',
43
59
  tips: [
44
60
  'Filter by tags (list_tasks tag="bug"), search by keyword (search_tasks), get full detail (get_task id).',
45
61
  'get_analytics for time spent & completion rates. Dependencies show in the dependency graph.',
46
62
  'list_tasks/search_tasks return compact summaries. Use get_task(id) to read full descriptions.',
63
+ 'log_debug accepts task_id OR project_id — use project_id for project-wide notes visible on the project page.',
47
64
  ],
48
65
  };
49
66
  return successResponse(instructions);
package/dist/types.d.ts CHANGED
@@ -27,6 +27,17 @@ export declare const NotificationType: z.ZodEnum<{
27
27
  error: "error";
28
28
  }>;
29
29
  export type NotificationType = z.infer<typeof NotificationType>;
30
+ export declare const AgentMessageStatus: z.ZodEnum<{
31
+ pending: "pending";
32
+ answered: "answered";
33
+ dismissed: "dismissed";
34
+ }>;
35
+ export type AgentMessageStatus = z.infer<typeof AgentMessageStatus>;
36
+ export declare const AgentStatus: z.ZodEnum<{
37
+ connected: "connected";
38
+ disconnected: "disconnected";
39
+ }>;
40
+ export type AgentStatus = z.infer<typeof AgentStatus>;
30
41
  export declare const ActivityAction: z.ZodEnum<{
31
42
  task_created: "task_created";
32
43
  task_deleted: "task_deleted";
@@ -51,10 +62,14 @@ export declare const ActivityAction: z.ZodEnum<{
51
62
  tag_added: "tag_added";
52
63
  tag_removed: "tag_removed";
53
64
  debug_log: "debug_log";
65
+ agent_question: "agent_question";
66
+ agent_question_answered: "agent_question_answered";
67
+ agent_connected: "agent_connected";
68
+ agent_disconnected: "agent_disconnected";
54
69
  }>;
55
70
  export type ActivityAction = z.infer<typeof ActivityAction>;
56
71
  export declare const VALID_TRANSITIONS: Record<TaskStatus, TaskStatus[]>;
57
- export type ErrorCode = 'NOT_FOUND' | 'INVALID_TRANSITION' | 'VALIDATION_ERROR' | 'CYCLE_DETECTED' | 'SESSION_ALREADY_ACTIVE' | 'NO_ACTIVE_SESSION';
72
+ export type ErrorCode = 'NOT_FOUND' | 'INVALID_TRANSITION' | 'VALIDATION_ERROR' | 'CYCLE_DETECTED' | 'SESSION_ALREADY_ACTIVE' | 'NO_ACTIVE_SESSION' | 'ALREADY_ANSWERED';
58
73
  export declare const LinkSchema: z.ZodObject<{
59
74
  label: z.ZodString;
60
75
  url: z.ZodString;
package/dist/types.js CHANGED
@@ -5,6 +5,8 @@ export const TaskStatus = z.enum([
5
5
  export const TaskPriority = z.enum(['low', 'medium', 'high', 'critical']);
6
6
  export const ProjectType = z.enum(['active_project', 'project_idea']);
7
7
  export const NotificationType = z.enum(['info', 'success', 'warning', 'error']);
8
+ export const AgentMessageStatus = z.enum(['pending', 'answered', 'dismissed']);
9
+ export const AgentStatus = z.enum(['connected', 'disconnected']);
8
10
  export const ActivityAction = z.enum([
9
11
  'task_created', 'task_deleted', 'task_status_changed', 'task_completed',
10
12
  'task_partial_done', 'timer_started', 'timer_paused', 'timer_stopped',
@@ -12,6 +14,8 @@ export const ActivityAction = z.enum([
12
14
  'tasks_bulk_created', 'settings_saved', 'data_seeded', 'data_cleared',
13
15
  'task_linked', 'task_unlinked', 'dependency_added', 'dependency_removed',
14
16
  'link_added', 'tag_added', 'tag_removed', 'debug_log',
17
+ 'agent_question', 'agent_question_answered',
18
+ 'agent_connected', 'agent_disconnected',
15
19
  ]);
16
20
  export const VALID_TRANSITIONS = {
17
21
  not_started: ['in_progress', 'blocked'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalmasonto/taskflow-mcp",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
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",