@dalmasonto/taskflow-mcp 1.0.8 → 1.0.10
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.js +7 -2
- package/dist/sse.js +1 -1
- package/dist/tmux-bridge.d.ts +1 -1
- package/dist/tmux-bridge.js +7 -151
- package/dist/tools/agent-inbox.js +58 -3
- package/package.json +1 -1
package/dist/agent-registry.js
CHANGED
|
@@ -47,14 +47,19 @@ export function registerAgent(options) {
|
|
|
47
47
|
const agentPid = process.ppid;
|
|
48
48
|
const projectPath = process.cwd();
|
|
49
49
|
const folderName = projectPath.split('/').pop() || 'unknown';
|
|
50
|
-
// Check if this PID already has a registration — reuse it
|
|
50
|
+
// Check if this PID already has a registration — reuse it (or rename if customName provided)
|
|
51
51
|
const existingByPid = db.prepare('SELECT * FROM agent_registry WHERE pid = ? AND status = ?').get(agentPid, 'connected');
|
|
52
52
|
if (existingByPid) {
|
|
53
|
-
// Update tmux pane in case it changed, but keep the same name
|
|
54
53
|
const tmuxPane = detectTmuxPane(agentPid);
|
|
55
54
|
if (tmuxPane !== existingByPid.tmux_pane) {
|
|
56
55
|
db.prepare('UPDATE agent_registry SET tmux_pane = ? WHERE id = ?').run(tmuxPane, existingByPid.id);
|
|
57
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
|
+
}
|
|
58
63
|
return existingByPid.name;
|
|
59
64
|
}
|
|
60
65
|
// Clean up dead agents first to free up names
|
package/dist/sse.js
CHANGED
|
@@ -287,7 +287,7 @@ export async function startSSEServer() {
|
|
|
287
287
|
return;
|
|
288
288
|
}
|
|
289
289
|
const ts = new Date().toISOString();
|
|
290
|
-
db.prepare('UPDATE agent_messages SET response = ?, status = ?, answered_at =
|
|
290
|
+
db.prepare('UPDATE agent_messages SET response = ?, status = ?, answered_at = ?, delivered = NULL WHERE id = ?')
|
|
291
291
|
.run(response, 'answered', ts, id);
|
|
292
292
|
const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
|
|
293
293
|
broadcast('agent_question_answered', { entity: 'agent_message', action: 'agent_question_answered', payload: updated });
|
package/dist/tmux-bridge.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ interface BridgeOptions {
|
|
|
4
4
|
tmuxPane: string;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* Start the tmux bridge: SSE listener for instant delivery
|
|
7
|
+
* Start the tmux bridge: SSE listener for instant message delivery.
|
|
8
8
|
* Returns a cleanup function for graceful shutdown.
|
|
9
9
|
*/
|
|
10
10
|
export declare function startTmuxBridge(options: BridgeOptions): () => void;
|
package/dist/tmux-bridge.js
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
-
import { createReadStream, writeFileSync, unlinkSync, watchFile, unwatchFile, existsSync } from 'fs';
|
|
3
2
|
import { getDb } from './db.js';
|
|
4
3
|
import { getActivePort } from './sse.js';
|
|
5
4
|
import http from 'http';
|
|
6
|
-
// ─── UI-injected message prefixes (skip these in capture) ────────────
|
|
7
|
-
const INJECTED_PREFIXES = [
|
|
8
|
-
'[Message from ',
|
|
9
|
-
'[Inbox Response]',
|
|
10
|
-
];
|
|
11
|
-
function isInjectedLine(line) {
|
|
12
|
-
return INJECTED_PREFIXES.some(prefix => line.startsWith(prefix));
|
|
13
|
-
}
|
|
14
|
-
// ─── Strip ANSI escape codes ─────────────────────────────────────────
|
|
15
|
-
const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
16
|
-
function stripAnsi(text) {
|
|
17
|
-
return text.replace(ANSI_RE, '');
|
|
18
|
-
}
|
|
19
5
|
// ─── SSE Listener (replaces the 3s poller) ───────────────────────────
|
|
20
6
|
function startSSEListener(options) {
|
|
21
7
|
const { agentName, agentPid, tmuxPane } = options;
|
|
@@ -66,7 +52,6 @@ function handleSSEEvent(event, data, options) {
|
|
|
66
52
|
if (!payload)
|
|
67
53
|
return;
|
|
68
54
|
if (event === 'agent_question') {
|
|
69
|
-
// Incoming message addressed to this agent
|
|
70
55
|
const recipient = payload.recipient_name;
|
|
71
56
|
const sender = payload.sender_name;
|
|
72
57
|
const status = payload.status;
|
|
@@ -84,14 +69,13 @@ function handleSSEEvent(event, data, options) {
|
|
|
84
69
|
injectAndMarkDelivered(id, text, tmuxPane);
|
|
85
70
|
}
|
|
86
71
|
if (event === 'agent_question_answered') {
|
|
87
|
-
// A question this agent sent got answered
|
|
88
72
|
const sender = payload.sender_name;
|
|
89
73
|
const recipient = payload.recipient_name;
|
|
90
74
|
const status = payload.status;
|
|
91
75
|
const id = payload.id;
|
|
92
76
|
const delivered = payload.delivered;
|
|
93
77
|
const agentPidField = payload.agent_pid;
|
|
94
|
-
const isOurs =
|
|
78
|
+
const isOurs = sender === agentName || agentPidField === agentPid;
|
|
95
79
|
if (!isOurs || status !== 'answered' || delivered === 1)
|
|
96
80
|
return;
|
|
97
81
|
const question = (payload.question || '').slice(0, 60);
|
|
@@ -105,6 +89,8 @@ function injectAndMarkDelivered(id, text, tmuxPane) {
|
|
|
105
89
|
db.prepare('UPDATE agent_messages SET delivered = 1 WHERE id = ?').run(id);
|
|
106
90
|
try {
|
|
107
91
|
execSync(`tmux send-keys -t ${tmuxPane} ${JSON.stringify(text)} Enter`, { stdio: 'ignore', timeout: 5000 });
|
|
92
|
+
// Long text triggers tmux bracketed paste — delay then send extra Enter to confirm
|
|
93
|
+
execSync(`sleep 1 && tmux send-keys -t ${tmuxPane} Enter`, { stdio: 'ignore', timeout: 5000 });
|
|
108
94
|
console.error(`[bridge] delivered message ${id} to tmux pane ${tmuxPane}`);
|
|
109
95
|
}
|
|
110
96
|
catch (err) {
|
|
@@ -116,8 +102,8 @@ function deliverUndelivered(options) {
|
|
|
116
102
|
const db = getDb();
|
|
117
103
|
const incoming = db.prepare(`SELECT * FROM agent_messages WHERE delivered IS NULL AND (
|
|
118
104
|
(recipient_name = ? AND status = 'pending') OR
|
|
119
|
-
(sender_name = ? AND
|
|
120
|
-
(agent_pid = ? AND
|
|
105
|
+
(sender_name = ? AND status = 'answered') OR
|
|
106
|
+
(agent_pid = ? AND status = 'answered')
|
|
121
107
|
)`).all(agentName, agentName, agentPid);
|
|
122
108
|
for (const msg of incoming) {
|
|
123
109
|
let text;
|
|
@@ -136,145 +122,15 @@ function deliverUndelivered(options) {
|
|
|
136
122
|
injectAndMarkDelivered(msg.id, text, tmuxPane);
|
|
137
123
|
}
|
|
138
124
|
}
|
|
139
|
-
// ─── Tmux Capture (terminal output → chat) ───────────────────────────
|
|
140
|
-
const MAX_MESSAGE_LENGTH = 10000;
|
|
141
|
-
const FLUSH_DELAY_MS = 2000;
|
|
142
|
-
function startCapture(options) {
|
|
143
|
-
const { agentName, tmuxPane } = options;
|
|
144
|
-
const tmpFile = `/tmp/taskflow-capture-${agentName}.pipe`;
|
|
145
|
-
const port = getActivePort();
|
|
146
|
-
// Truncate/create the temp file
|
|
147
|
-
writeFileSync(tmpFile, '');
|
|
148
|
-
// Start tmux pipe-pane
|
|
149
|
-
try {
|
|
150
|
-
execSync(`tmux pipe-pane -t ${tmuxPane} -o "cat >> ${tmpFile}"`, { stdio: 'ignore' });
|
|
151
|
-
console.error(`[capture] started pipe-pane for ${tmuxPane} → ${tmpFile}`);
|
|
152
|
-
}
|
|
153
|
-
catch (err) {
|
|
154
|
-
console.error('[capture] failed to start pipe-pane:', err);
|
|
155
|
-
return () => { };
|
|
156
|
-
}
|
|
157
|
-
let buffer = '';
|
|
158
|
-
let flushTimer = null;
|
|
159
|
-
let readPosition = 0;
|
|
160
|
-
function flushBuffer() {
|
|
161
|
-
flushTimer = null;
|
|
162
|
-
if (!buffer.trim()) {
|
|
163
|
-
buffer = '';
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
// Split into chunks if needed
|
|
167
|
-
const chunks = [];
|
|
168
|
-
let remaining = buffer;
|
|
169
|
-
while (remaining.length > MAX_MESSAGE_LENGTH) {
|
|
170
|
-
chunks.push(remaining.slice(0, MAX_MESSAGE_LENGTH));
|
|
171
|
-
remaining = remaining.slice(MAX_MESSAGE_LENGTH);
|
|
172
|
-
}
|
|
173
|
-
if (remaining.trim())
|
|
174
|
-
chunks.push(remaining);
|
|
175
|
-
buffer = '';
|
|
176
|
-
for (const chunk of chunks) {
|
|
177
|
-
postToChat(agentName, chunk, port);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
function processNewData() {
|
|
181
|
-
if (!existsSync(tmpFile))
|
|
182
|
-
return;
|
|
183
|
-
const stream = createReadStream(tmpFile, {
|
|
184
|
-
start: readPosition,
|
|
185
|
-
encoding: 'utf-8',
|
|
186
|
-
});
|
|
187
|
-
let newData = '';
|
|
188
|
-
stream.on('data', (chunk) => { newData += chunk.toString(); });
|
|
189
|
-
stream.on('end', () => {
|
|
190
|
-
if (!newData)
|
|
191
|
-
return;
|
|
192
|
-
readPosition += Buffer.byteLength(newData);
|
|
193
|
-
const cleaned = stripAnsi(newData);
|
|
194
|
-
const lines = cleaned.split('\n');
|
|
195
|
-
const filtered = lines.filter(line => !isInjectedLine(line.trim()));
|
|
196
|
-
const text = filtered.join('\n');
|
|
197
|
-
if (text.trim()) {
|
|
198
|
-
buffer += text;
|
|
199
|
-
// Reset the flush timer on each new data
|
|
200
|
-
if (flushTimer)
|
|
201
|
-
clearTimeout(flushTimer);
|
|
202
|
-
flushTimer = setTimeout(flushBuffer, FLUSH_DELAY_MS);
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
stream.on('error', () => { });
|
|
206
|
-
}
|
|
207
|
-
// Watch for file changes
|
|
208
|
-
watchFile(tmpFile, { interval: 500 }, () => {
|
|
209
|
-
processNewData();
|
|
210
|
-
});
|
|
211
|
-
// Cleanup function
|
|
212
|
-
return () => {
|
|
213
|
-
if (flushTimer)
|
|
214
|
-
clearTimeout(flushTimer);
|
|
215
|
-
// Flush any remaining buffer
|
|
216
|
-
if (buffer.trim()) {
|
|
217
|
-
const chunks = [];
|
|
218
|
-
let remaining = buffer;
|
|
219
|
-
while (remaining.length > MAX_MESSAGE_LENGTH) {
|
|
220
|
-
chunks.push(remaining.slice(0, MAX_MESSAGE_LENGTH));
|
|
221
|
-
remaining = remaining.slice(MAX_MESSAGE_LENGTH);
|
|
222
|
-
}
|
|
223
|
-
if (remaining.trim())
|
|
224
|
-
chunks.push(remaining);
|
|
225
|
-
buffer = '';
|
|
226
|
-
for (const chunk of chunks) {
|
|
227
|
-
postToChat(agentName, chunk, port);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
unwatchFile(tmpFile);
|
|
231
|
-
try {
|
|
232
|
-
execSync(`tmux pipe-pane -t ${tmuxPane}`, { stdio: 'ignore' });
|
|
233
|
-
}
|
|
234
|
-
catch { }
|
|
235
|
-
try {
|
|
236
|
-
unlinkSync(tmpFile);
|
|
237
|
-
}
|
|
238
|
-
catch { }
|
|
239
|
-
console.error(`[capture] stopped for ${tmuxPane}`);
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
function postToChat(agentName, text, port) {
|
|
243
|
-
const body = JSON.stringify({
|
|
244
|
-
recipient: 'user',
|
|
245
|
-
message: text,
|
|
246
|
-
source: 'terminal',
|
|
247
|
-
senderName: agentName,
|
|
248
|
-
});
|
|
249
|
-
const req = http.request({
|
|
250
|
-
hostname: 'localhost',
|
|
251
|
-
port,
|
|
252
|
-
path: '/api/agent-messages/send',
|
|
253
|
-
method: 'POST',
|
|
254
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
255
|
-
}, (res) => {
|
|
256
|
-
// Drain the response
|
|
257
|
-
res.resume();
|
|
258
|
-
if (res.statusCode !== 200) {
|
|
259
|
-
console.error(`[capture] POST failed with status ${res.statusCode}`);
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
req.on('error', (err) => {
|
|
263
|
-
console.error('[capture] POST error:', err.message);
|
|
264
|
-
});
|
|
265
|
-
req.write(body);
|
|
266
|
-
req.end();
|
|
267
|
-
}
|
|
268
125
|
// ─── Public API ──────────────────────────────────────────────────────
|
|
269
126
|
/**
|
|
270
|
-
* Start the tmux bridge: SSE listener for instant delivery
|
|
127
|
+
* Start the tmux bridge: SSE listener for instant message delivery.
|
|
271
128
|
* Returns a cleanup function for graceful shutdown.
|
|
272
129
|
*/
|
|
273
130
|
export function startTmuxBridge(options) {
|
|
274
131
|
startSSEListener(options);
|
|
275
|
-
const stopCapture = startCapture(options);
|
|
276
132
|
console.error(`[bridge] tmux bridge active for agent "${options.agentName}" on pane ${options.tmuxPane}`);
|
|
277
133
|
return () => {
|
|
278
|
-
|
|
134
|
+
// SSE connection will close when process exits
|
|
279
135
|
};
|
|
280
136
|
}
|
|
@@ -44,8 +44,8 @@ export function registerAgentInboxTools(server) {
|
|
|
44
44
|
message: `Question posted to Agent Inbox (id: ${id}). Use check_response(${id}) to retrieve the user's answer.`,
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
|
-
server.tool('check_response', 'Check if
|
|
48
|
-
message_id: z.number().describe('The
|
|
47
|
+
server.tool('check_response', 'Check if a previously posted question (via ask_user or ask_agent) has been answered. Returns the response if answered, or status "pending" if still waiting.', {
|
|
48
|
+
message_id: z.number().describe('The message ID returned by ask_user or ask_agent'),
|
|
49
49
|
}, async (params) => {
|
|
50
50
|
const db = getDb();
|
|
51
51
|
const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(params.message_id);
|
|
@@ -54,12 +54,14 @@ export function registerAgentInboxTools(server) {
|
|
|
54
54
|
if (message.status === 'answered') {
|
|
55
55
|
return successResponse({
|
|
56
56
|
id: message.id, status: 'answered', response: message.response,
|
|
57
|
+
respondedBy: message.recipient_name,
|
|
57
58
|
question: message.question, answered_at: message.answered_at,
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
61
|
return successResponse({
|
|
61
62
|
id: message.id, status: 'pending', question: message.question,
|
|
62
|
-
|
|
63
|
+
recipient: message.recipient_name,
|
|
64
|
+
message: 'No response yet. Try again later or continue with other work.',
|
|
63
65
|
});
|
|
64
66
|
});
|
|
65
67
|
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).', {
|
|
@@ -80,6 +82,59 @@ export function registerAgentInboxTools(server) {
|
|
|
80
82
|
broadcastChange('agent_message', 'agent_question', msg);
|
|
81
83
|
return successResponse({ id, sender: senderName, recipient: params.recipient, status: 'pending' });
|
|
82
84
|
});
|
|
85
|
+
server.tool('ask_agent', 'Ask another agent a question and wait for their response. Like ask_user but targets an agent. Returns the message ID — use check_response to poll for the answer. The recipient agent receives the question in their terminal (if in tmux) and can respond with respond_to_message.', {
|
|
86
|
+
recipient: z.string().describe('Name of the target agent (e.g. "backend", "task_flow:2")'),
|
|
87
|
+
question: z.string().describe('The question to ask'),
|
|
88
|
+
context: z.string().optional().describe('Markdown context — background info, code snippets, proposals'),
|
|
89
|
+
choices: z.array(z.string()).optional().describe('Optional quick-tap choices, e.g. ["Yes", "No", "Skip"]'),
|
|
90
|
+
project_id: z.number().optional().describe('Optional project ID to attach the question to'),
|
|
91
|
+
}, async (params) => {
|
|
92
|
+
const db = getDb();
|
|
93
|
+
const senderName = ensureRegistered();
|
|
94
|
+
const recipient = getAgent(params.recipient);
|
|
95
|
+
if (!recipient)
|
|
96
|
+
return errorResponse(`Agent "${params.recipient}" not found or not connected`, 'NOT_FOUND');
|
|
97
|
+
const ts = now();
|
|
98
|
+
const result = db.prepare(`INSERT INTO agent_messages (project_id, question, context, choices, sender_name, recipient_name, agent_pid, source, status, created_at)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'mcp', 'pending', ?)`).run(params.project_id ?? null, params.question, params.context ?? null, params.choices ? JSON.stringify(params.choices) : null, senderName, params.recipient, process.ppid, ts);
|
|
100
|
+
const id = result.lastInsertRowid;
|
|
101
|
+
const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
|
|
102
|
+
broadcastChange('agent_message', 'agent_question', msg);
|
|
103
|
+
logActivity('agent_question', `Asked ${params.recipient}: ${params.question}`, { entityType: 'agent_message', entityId: id });
|
|
104
|
+
return successResponse({
|
|
105
|
+
id,
|
|
106
|
+
status: 'pending',
|
|
107
|
+
sender: senderName,
|
|
108
|
+
recipient: params.recipient,
|
|
109
|
+
message: `Question sent to "${params.recipient}" (id: ${id}). Use check_response(${id}) to retrieve their answer.`,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
server.tool('respond_to_message', 'Respond to a pending message addressed to this agent. Use check_messages to see incoming messages, then respond by message ID.', {
|
|
113
|
+
message_id: z.number().describe('The message ID to respond to (from check_messages)'),
|
|
114
|
+
response: z.string().describe('Your response text'),
|
|
115
|
+
}, async (params) => {
|
|
116
|
+
const db = getDb();
|
|
117
|
+
const name = ensureRegistered();
|
|
118
|
+
const message = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(params.message_id);
|
|
119
|
+
if (!message)
|
|
120
|
+
return errorResponse(`Message ${params.message_id} not found`, 'NOT_FOUND');
|
|
121
|
+
if (message.recipient_name !== name)
|
|
122
|
+
return errorResponse(`Message ${params.message_id} is not addressed to you`, 'VALIDATION_ERROR');
|
|
123
|
+
if (message.status !== 'pending')
|
|
124
|
+
return errorResponse(`Message ${params.message_id} is already ${message.status}`, 'VALIDATION_ERROR');
|
|
125
|
+
const ts = now();
|
|
126
|
+
db.prepare('UPDATE agent_messages SET response = ?, status = ?, answered_at = ?, delivered = NULL WHERE id = ?')
|
|
127
|
+
.run(params.response, 'answered', ts, params.message_id);
|
|
128
|
+
const updated = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(params.message_id);
|
|
129
|
+
broadcastChange('agent_message', 'agent_question_answered', updated);
|
|
130
|
+
logActivity('agent_question_answered', `Responded to ${message.sender_name}: ${params.response.slice(0, 80)}`, { entityType: 'agent_message', entityId: params.message_id });
|
|
131
|
+
return successResponse({
|
|
132
|
+
id: params.message_id,
|
|
133
|
+
status: 'answered',
|
|
134
|
+
sender: message.sender_name,
|
|
135
|
+
message: `Response sent to "${message.sender_name}".`,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
83
138
|
server.tool('check_messages', 'Check for incoming messages from users or other agents addressed to this agent.', {}, async () => {
|
|
84
139
|
const db = getDb();
|
|
85
140
|
const name = ensureRegistered();
|