@dalmasonto/taskflow-mcp 1.0.32 → 1.0.35
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 +2 -1
- package/dist/agent-registry.js +6 -1
- package/dist/index.js +41 -4
- package/dist/retry.d.ts +34 -0
- package/dist/retry.js +94 -0
- package/dist/sse.js +71 -13
- package/dist/tmux-bridge.d.ts +2 -1
- package/dist/tmux-bridge.js +82 -22
- package/dist/tools/agent-inbox.js +15 -0
- package/dist/types.d.ts +2 -1
- package/dist/types.js +1 -1
- package/package.json +1 -1
package/dist/agent-registry.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export declare function registerAgent(options?: {
|
|
|
18
18
|
}): string;
|
|
19
19
|
/** Mark an agent as disconnected */
|
|
20
20
|
export declare function unregisterAgent(name: string): void;
|
|
21
|
-
/** Check all registered agents and mark dead ones as disconnected
|
|
21
|
+
/** Check all registered agents and mark dead ones as disconnected.
|
|
22
|
+
* Also purges entries that have been disconnected for more than 24 hours. */
|
|
22
23
|
export declare function checkAgentLiveness(): void;
|
|
23
24
|
/** Get a registered agent by name */
|
|
24
25
|
export declare function getAgent(name: string): AgentRow | undefined;
|
package/dist/agent-registry.js
CHANGED
|
@@ -129,15 +129,20 @@ export function unregisterAgent(name) {
|
|
|
129
129
|
broadcast('agent_disconnected', { entity: 'agent', action: 'agent_disconnected', payload: row });
|
|
130
130
|
logActivity('agent_disconnected', `Agent "${name}" disconnected`, { entityType: 'agent' });
|
|
131
131
|
}
|
|
132
|
-
/** Check all registered agents and mark dead ones as disconnected
|
|
132
|
+
/** Check all registered agents and mark dead ones as disconnected.
|
|
133
|
+
* Also purges entries that have been disconnected for more than 24 hours. */
|
|
133
134
|
export function checkAgentLiveness() {
|
|
134
135
|
const db = getDb();
|
|
136
|
+
// Mark dead connected agents as disconnected
|
|
135
137
|
const liveAgents = db.prepare("SELECT * FROM agent_registry WHERE status = 'connected'").all();
|
|
136
138
|
for (const agent of liveAgents) {
|
|
137
139
|
if (!isAlive(agent.pid)) {
|
|
138
140
|
unregisterAgent(agent.name);
|
|
139
141
|
}
|
|
140
142
|
}
|
|
143
|
+
// Purge entries that have been disconnected for more than 24 hours
|
|
144
|
+
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
145
|
+
db.prepare("DELETE FROM agent_registry WHERE status = 'disconnected' AND disconnected_at IS NOT NULL AND disconnected_at < ?").run(cutoff);
|
|
141
146
|
}
|
|
142
147
|
/** Get a registered agent by name */
|
|
143
148
|
export function getAgent(name) {
|
package/dist/index.js
CHANGED
|
@@ -70,6 +70,33 @@ if (!httpOnly) {
|
|
|
70
70
|
'Use log_debug to document your work — it is shared memory visible to the user and other agents.',
|
|
71
71
|
].join('\n'),
|
|
72
72
|
});
|
|
73
|
+
// Filled in after agent registration — used by the inbox drain below
|
|
74
|
+
let agentNameGetter = () => 'unknown';
|
|
75
|
+
// Drain any undelivered inbox messages for this agent and prepend them to a
|
|
76
|
+
// tool result. This is the non-tmux delivery path: since we can't inject into
|
|
77
|
+
// stdin, we piggyback pending messages onto the next tool response so Claude
|
|
78
|
+
// sees them in its context without any terminal tricks.
|
|
79
|
+
function drainInboxNotice() {
|
|
80
|
+
try {
|
|
81
|
+
const name = agentNameGetter();
|
|
82
|
+
if (name === 'unknown')
|
|
83
|
+
return '';
|
|
84
|
+
const db = getDb();
|
|
85
|
+
const pending = db.prepare(`SELECT id, sender_name, question FROM agent_messages
|
|
86
|
+
WHERE recipient_name = ? AND status = 'pending' AND delivered IS NULL
|
|
87
|
+
ORDER BY created_at ASC`).all(name);
|
|
88
|
+
if (pending.length === 0)
|
|
89
|
+
return '';
|
|
90
|
+
for (const msg of pending) {
|
|
91
|
+
db.prepare('UPDATE agent_messages SET delivered = 1 WHERE id = ?').run(msg.id);
|
|
92
|
+
}
|
|
93
|
+
const lines = pending.map(m => ` [Inbox #${m.id} from ${m.sender_name}]: ${m.question}`).join('\n');
|
|
94
|
+
return `\n⚠️ INBOX MESSAGE(S) — respond before continuing:\n${lines}\n`;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
73
100
|
// Wrap server.tool() to track execution time and log failures
|
|
74
101
|
const originalTool = server.tool.bind(server);
|
|
75
102
|
server.tool = function (...args) {
|
|
@@ -114,6 +141,13 @@ if (!httpOnly) {
|
|
|
114
141
|
broadcast('tool_executed', { entity: 'tool', action: 'tool_executed', payload: { tool_name: toolName, duration_ms: duration, success: true, created_at: ts } });
|
|
115
142
|
}
|
|
116
143
|
catch { /* don't break tool execution */ }
|
|
144
|
+
// Non-tmux inbox delivery: prepend any pending messages to the result
|
|
145
|
+
const notice = drainInboxNotice();
|
|
146
|
+
if (notice && result?.content) {
|
|
147
|
+
const first = result.content.find((c) => c.type === 'text');
|
|
148
|
+
if (first)
|
|
149
|
+
first.text = notice + first.text;
|
|
150
|
+
}
|
|
117
151
|
return result;
|
|
118
152
|
}
|
|
119
153
|
catch (err) {
|
|
@@ -150,6 +184,7 @@ if (!httpOnly) {
|
|
|
150
184
|
// Auto-register this agent and sync name to agent-inbox tools
|
|
151
185
|
const agentName = registerAgent();
|
|
152
186
|
setAgentName(agentName);
|
|
187
|
+
agentNameGetter = getAgentName; // wire up inbox drain now that we have a name
|
|
153
188
|
const agentPid = process.ppid;
|
|
154
189
|
console.error(`[agent] registered as "${agentName}"`);
|
|
155
190
|
let cleanup = () => { try {
|
|
@@ -175,13 +210,18 @@ if (!httpOnly) {
|
|
|
175
210
|
catch {
|
|
176
211
|
console.error('[bridge] tmux not available');
|
|
177
212
|
}
|
|
178
|
-
|
|
213
|
+
// Always start the bridge — tmuxPane may be null for non-tmux sessions,
|
|
214
|
+
// in which case messages are delivered via stderr instead of tmux injection.
|
|
215
|
+
{
|
|
179
216
|
const { startTmuxBridge } = await import('./tmux-bridge.js');
|
|
180
217
|
const stopBridge = startTmuxBridge({
|
|
181
218
|
getAgentName,
|
|
182
219
|
agentPid,
|
|
183
220
|
tmuxPane: tmuxTarget,
|
|
184
221
|
});
|
|
222
|
+
if (!tmuxTarget) {
|
|
223
|
+
console.error('[bridge] agent not in tmux — using stderr delivery for inbox messages');
|
|
224
|
+
}
|
|
185
225
|
const originalCleanup = cleanup;
|
|
186
226
|
cleanup = () => { stopBridge(); originalCleanup(); };
|
|
187
227
|
process.removeListener('SIGINT', originalCleanup);
|
|
@@ -189,7 +229,4 @@ if (!httpOnly) {
|
|
|
189
229
|
process.on('SIGINT', cleanup);
|
|
190
230
|
process.on('SIGTERM', cleanup);
|
|
191
231
|
}
|
|
192
|
-
else {
|
|
193
|
-
console.error('[bridge] agent not in tmux — bridge disabled');
|
|
194
|
-
}
|
|
195
232
|
}
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry policy engine for TaskFlow.
|
|
3
|
+
* Provides exponential backoff with jitter for network/external calls.
|
|
4
|
+
*/
|
|
5
|
+
export interface RetryPolicy {
|
|
6
|
+
maxRetries: number;
|
|
7
|
+
initialBackoffMs: number;
|
|
8
|
+
maxBackoffMs: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const DEFAULT_RETRY_POLICY: RetryPolicy;
|
|
11
|
+
/** Thrown when an HTTP response has a retryable status code */
|
|
12
|
+
export declare class RetryableHttpError extends Error {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
constructor(status: number, message: string);
|
|
15
|
+
}
|
|
16
|
+
/** Thrown when all retry attempts are exhausted */
|
|
17
|
+
export declare class RetriesExhaustedError extends Error {
|
|
18
|
+
readonly attempts: number;
|
|
19
|
+
readonly lastError: unknown;
|
|
20
|
+
constructor(attempts: number, lastError: unknown);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Execute `fn` with automatic retry on transient errors.
|
|
24
|
+
*
|
|
25
|
+
* @param label Short description for activity log entries (e.g. "relay push")
|
|
26
|
+
* @param fn Async function to execute. Should throw RetryableHttpError for bad HTTP responses.
|
|
27
|
+
* @param policy Retry configuration (defaults to DEFAULT_RETRY_POLICY)
|
|
28
|
+
*/
|
|
29
|
+
export declare function withRetry<T>(label: string, fn: () => Promise<T>, policy?: RetryPolicy): Promise<T>;
|
|
30
|
+
/**
|
|
31
|
+
* Wrapper around fetch() that throws RetryableHttpError for transient HTTP failures.
|
|
32
|
+
* Drop-in for fetch() calls that should participate in retry logic.
|
|
33
|
+
*/
|
|
34
|
+
export declare function fetchWithRetry(label: string, url: string, options?: RequestInit, policy?: RetryPolicy): Promise<Response>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry policy engine for TaskFlow.
|
|
3
|
+
* Provides exponential backoff with jitter for network/external calls.
|
|
4
|
+
*/
|
|
5
|
+
import { logActivity } from './helpers.js';
|
|
6
|
+
export const DEFAULT_RETRY_POLICY = {
|
|
7
|
+
maxRetries: 2,
|
|
8
|
+
initialBackoffMs: 200,
|
|
9
|
+
maxBackoffMs: 2000,
|
|
10
|
+
};
|
|
11
|
+
/** HTTP status codes considered transient and safe to retry */
|
|
12
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
|
|
13
|
+
/** Classify whether an error is worth retrying */
|
|
14
|
+
function isRetryable(err) {
|
|
15
|
+
if (err instanceof RetriesExhaustedError)
|
|
16
|
+
return false;
|
|
17
|
+
if (err instanceof TypeError)
|
|
18
|
+
return true; // network failure, DNS, ECONNREFUSED
|
|
19
|
+
if (err instanceof RetryableHttpError)
|
|
20
|
+
return true;
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
/** Thrown when an HTTP response has a retryable status code */
|
|
24
|
+
export class RetryableHttpError extends Error {
|
|
25
|
+
status;
|
|
26
|
+
constructor(status, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.name = 'RetryableHttpError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Thrown when all retry attempts are exhausted */
|
|
33
|
+
export class RetriesExhaustedError extends Error {
|
|
34
|
+
attempts;
|
|
35
|
+
lastError;
|
|
36
|
+
constructor(attempts, lastError) {
|
|
37
|
+
const cause = lastError instanceof Error ? lastError.message : String(lastError);
|
|
38
|
+
super(`Retries exhausted after ${attempts} attempt(s): ${cause}`);
|
|
39
|
+
this.attempts = attempts;
|
|
40
|
+
this.lastError = lastError;
|
|
41
|
+
this.name = 'RetriesExhaustedError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Compute the next backoff delay with full jitter.
|
|
46
|
+
* delay = min(initial * 2^attempt, max) * random(0.5, 1.0)
|
|
47
|
+
*/
|
|
48
|
+
function backoffMs(policy, attempt) {
|
|
49
|
+
const base = Math.min(policy.initialBackoffMs * Math.pow(2, attempt), policy.maxBackoffMs);
|
|
50
|
+
return Math.floor(base * (0.5 + Math.random() * 0.5));
|
|
51
|
+
}
|
|
52
|
+
function sleep(ms) {
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Execute `fn` with automatic retry on transient errors.
|
|
57
|
+
*
|
|
58
|
+
* @param label Short description for activity log entries (e.g. "relay push")
|
|
59
|
+
* @param fn Async function to execute. Should throw RetryableHttpError for bad HTTP responses.
|
|
60
|
+
* @param policy Retry configuration (defaults to DEFAULT_RETRY_POLICY)
|
|
61
|
+
*/
|
|
62
|
+
export async function withRetry(label, fn, policy = DEFAULT_RETRY_POLICY) {
|
|
63
|
+
let lastError;
|
|
64
|
+
for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
return await fn();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
lastError = err;
|
|
70
|
+
if (!isRetryable(err) || attempt === policy.maxRetries) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
const delay = backoffMs(policy, attempt);
|
|
74
|
+
logActivity('debug_log', `[retry] ${label} — attempt ${attempt + 1} failed, retrying in ${delay}ms`, {
|
|
75
|
+
entityType: 'system',
|
|
76
|
+
});
|
|
77
|
+
await sleep(delay);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new RetriesExhaustedError(policy.maxRetries + 1, lastError);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Wrapper around fetch() that throws RetryableHttpError for transient HTTP failures.
|
|
84
|
+
* Drop-in for fetch() calls that should participate in retry logic.
|
|
85
|
+
*/
|
|
86
|
+
export async function fetchWithRetry(label, url, options, policy) {
|
|
87
|
+
return withRetry(label, async () => {
|
|
88
|
+
const res = await fetch(url, options);
|
|
89
|
+
if (RETRYABLE_STATUS_CODES.has(res.status)) {
|
|
90
|
+
throw new RetryableHttpError(res.status, `${label}: HTTP ${res.status}`);
|
|
91
|
+
}
|
|
92
|
+
return res;
|
|
93
|
+
}, policy);
|
|
94
|
+
}
|
package/dist/sse.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getDb } from './db.js';
|
|
|
4
4
|
import { logActivity } from './helpers.js';
|
|
5
5
|
import { getConfig } from './config.js';
|
|
6
6
|
import { validateKeys } from './tools/terminal.js';
|
|
7
|
+
import { fetchWithRetry } from './retry.js';
|
|
7
8
|
const SERVICE_ID = 'taskflow-mcp';
|
|
8
9
|
const PROBE_TIMEOUT_MS = 2000;
|
|
9
10
|
// ─── Relay upstream config (from config file, env vars, or .env) ────
|
|
@@ -48,10 +49,11 @@ function jsonResponse(res, status, data) {
|
|
|
48
49
|
res.end(JSON.stringify(data));
|
|
49
50
|
}
|
|
50
51
|
function readBody(req) {
|
|
51
|
-
return new Promise((resolve) => {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
52
53
|
let body = '';
|
|
53
54
|
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
54
55
|
req.on('end', () => resolve(body));
|
|
56
|
+
req.on('error', reject);
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
/**
|
|
@@ -289,7 +291,14 @@ export async function startSSEServer() {
|
|
|
289
291
|
jsonResponse(res, 404, { error: 'Task not found' });
|
|
290
292
|
return;
|
|
291
293
|
}
|
|
292
|
-
|
|
294
|
+
let body;
|
|
295
|
+
try {
|
|
296
|
+
body = JSON.parse(await readBody(req));
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
293
302
|
const fieldMap = {
|
|
294
303
|
title: 'title', description: 'description', status: 'status',
|
|
295
304
|
priority: 'priority', projectId: 'project_id', dueDate: 'due_date',
|
|
@@ -348,7 +357,14 @@ export async function startSSEServer() {
|
|
|
348
357
|
// POST /api/tasks — create a task
|
|
349
358
|
if (req.url === '/api/tasks' && req.method === 'POST') {
|
|
350
359
|
const db = getDb();
|
|
351
|
-
|
|
360
|
+
let body;
|
|
361
|
+
try {
|
|
362
|
+
body = JSON.parse(await readBody(req));
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
352
368
|
const ts = new Date().toISOString();
|
|
353
369
|
const result = db.prepare(`INSERT INTO tasks (title, description, status, priority, project_id, dependencies, links, tags, due_date, estimated_time, created_at, updated_at)
|
|
354
370
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(body.title, body.description ?? null, body.status ?? 'not_started', body.priority ?? 'medium', body.projectId ?? null, JSON.stringify(body.dependencies ?? []), JSON.stringify(body.links ?? []), JSON.stringify(body.tags ?? []), body.dueDate ? new Date(body.dueDate).toISOString() : null, body.estimatedTime ?? null, ts, ts);
|
|
@@ -361,7 +377,14 @@ export async function startSSEServer() {
|
|
|
361
377
|
// POST /api/sessions — create a timer session
|
|
362
378
|
if (req.url === '/api/sessions' && req.method === 'POST') {
|
|
363
379
|
const db = getDb();
|
|
364
|
-
|
|
380
|
+
let body;
|
|
381
|
+
try {
|
|
382
|
+
body = JSON.parse(await readBody(req));
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
365
388
|
const ts = new Date().toISOString();
|
|
366
389
|
const result = db.prepare('INSERT INTO sessions (task_id, start, end) VALUES (?, ?, ?)').run(body.taskId, body.start ? new Date(body.start).toISOString() : ts, body.end ? new Date(body.end).toISOString() : null);
|
|
367
390
|
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
|
|
@@ -374,7 +397,14 @@ export async function startSSEServer() {
|
|
|
374
397
|
if (sessionPatchMatch && req.method === 'PATCH') {
|
|
375
398
|
const db = getDb();
|
|
376
399
|
const id = Number(sessionPatchMatch[1]);
|
|
377
|
-
|
|
400
|
+
let body;
|
|
401
|
+
try {
|
|
402
|
+
body = JSON.parse(await readBody(req));
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
378
408
|
if (body.end) {
|
|
379
409
|
db.prepare('UPDATE sessions SET end = ? WHERE id = ?').run(new Date(body.end).toISOString(), id);
|
|
380
410
|
}
|
|
@@ -392,7 +422,14 @@ export async function startSSEServer() {
|
|
|
392
422
|
jsonResponse(res, 404, { error: 'Project not found' });
|
|
393
423
|
return;
|
|
394
424
|
}
|
|
395
|
-
|
|
425
|
+
let body;
|
|
426
|
+
try {
|
|
427
|
+
body = JSON.parse(await readBody(req));
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
396
433
|
const fieldMap = {
|
|
397
434
|
name: 'name', color: 'color', type: 'type', description: 'description',
|
|
398
435
|
};
|
|
@@ -432,7 +469,14 @@ export async function startSSEServer() {
|
|
|
432
469
|
}
|
|
433
470
|
// POST /api/broadcast — relay SSE events from other processes (e.g. MCP)
|
|
434
471
|
if (req.url === '/api/broadcast' && req.method === 'POST') {
|
|
435
|
-
|
|
472
|
+
let body;
|
|
473
|
+
try {
|
|
474
|
+
body = JSON.parse(await readBody(req));
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
436
480
|
if (body.event && body.data) {
|
|
437
481
|
broadcastLocal(body.event, body.data);
|
|
438
482
|
}
|
|
@@ -453,7 +497,14 @@ export async function startSSEServer() {
|
|
|
453
497
|
jsonResponse(res, 400, { error: 'Already answered' });
|
|
454
498
|
return;
|
|
455
499
|
}
|
|
456
|
-
|
|
500
|
+
let body;
|
|
501
|
+
try {
|
|
502
|
+
body = JSON.parse(await readBody(req));
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
457
508
|
const response = body.response;
|
|
458
509
|
if (!response) {
|
|
459
510
|
jsonResponse(res, 400, { error: 'Response is required' });
|
|
@@ -493,7 +544,14 @@ export async function startSSEServer() {
|
|
|
493
544
|
// POST /api/agent-messages/send — send a message (from user UI or capture system)
|
|
494
545
|
if (req.url === '/api/agent-messages/send' && req.method === 'POST') {
|
|
495
546
|
const db = getDb();
|
|
496
|
-
|
|
547
|
+
let body;
|
|
548
|
+
try {
|
|
549
|
+
body = JSON.parse(await readBody(req));
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
jsonResponse(res, 400, { error: 'Invalid JSON body' });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
497
555
|
const { recipient, message: msgText, projectId, source: msgSource, senderName } = body;
|
|
498
556
|
if (!recipient || !msgText) {
|
|
499
557
|
jsonResponse(res, 400, { error: 'recipient and message are required' });
|
|
@@ -690,7 +748,7 @@ export async function startSSEServer() {
|
|
|
690
748
|
// Poll for commands every 2s
|
|
691
749
|
setInterval(async () => {
|
|
692
750
|
try {
|
|
693
|
-
const res = await
|
|
751
|
+
const res = await fetchWithRetry('relay commands/pending', `${RELAY_URL}/commands/pending`, {
|
|
694
752
|
headers: { 'Authorization': `Bearer ${RELAY_PUSH_TOKEN}` },
|
|
695
753
|
});
|
|
696
754
|
if (!res.ok)
|
|
@@ -734,13 +792,13 @@ export function markSSEActive() {
|
|
|
734
792
|
* Also pushes to the remote relay server if configured.
|
|
735
793
|
*/
|
|
736
794
|
export function broadcast(event, data) {
|
|
737
|
-
if (sseServerActive
|
|
795
|
+
if (sseServerActive) {
|
|
738
796
|
broadcastLocal(event, data);
|
|
739
797
|
}
|
|
740
798
|
else {
|
|
741
|
-
// Relay to the SSE server owner via HTTP
|
|
799
|
+
// Relay to the SSE server owner via HTTP (with retry for transient failures)
|
|
742
800
|
const body = JSON.stringify({ event, data });
|
|
743
|
-
|
|
801
|
+
fetchWithRetry('broadcast relay', `http://localhost:${activePort}/api/broadcast`, {
|
|
744
802
|
method: 'POST',
|
|
745
803
|
headers: { 'Content-Type': 'application/json' },
|
|
746
804
|
body,
|
package/dist/tmux-bridge.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ interface BridgeOptions {
|
|
|
2
2
|
/** Returns the current agent name — must be a getter so renames are picked up */
|
|
3
3
|
getAgentName: () => string;
|
|
4
4
|
agentPid: number;
|
|
5
|
-
|
|
5
|
+
/** Null when not running inside tmux — falls back to stderr delivery */
|
|
6
|
+
tmuxPane: string | null;
|
|
6
7
|
}
|
|
7
8
|
/**
|
|
8
9
|
* Start the tmux bridge: SSE listener for instant message delivery.
|
package/dist/tmux-bridge.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
2
5
|
import { getDb } from './db.js';
|
|
3
6
|
import { getActivePort } from './sse.js';
|
|
4
7
|
import http from 'http';
|
|
8
|
+
const LOG_FILE = join(homedir(), '.taskflow', 'bridge.log');
|
|
9
|
+
try {
|
|
10
|
+
mkdirSync(join(homedir(), '.taskflow'), { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
catch { }
|
|
13
|
+
function blogLog(msg) {
|
|
14
|
+
const line = `${new Date().toISOString()} ${msg}\n`;
|
|
15
|
+
console.error(`[bridge] ${msg}`);
|
|
16
|
+
try {
|
|
17
|
+
appendFileSync(LOG_FILE, line);
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
}
|
|
5
21
|
// ─── SSE Listener (replaces the 3s poller) ───────────────────────────
|
|
6
22
|
function startSSEListener(options) {
|
|
7
23
|
const port = getActivePort();
|
|
@@ -32,16 +48,16 @@ function startSSEListener(options) {
|
|
|
32
48
|
}
|
|
33
49
|
});
|
|
34
50
|
res.on('end', () => {
|
|
35
|
-
|
|
51
|
+
blogLog('SSE connection closed, reconnecting in 3s...');
|
|
36
52
|
setTimeout(connect, 3000);
|
|
37
53
|
});
|
|
38
54
|
res.on('error', () => {
|
|
39
|
-
|
|
55
|
+
blogLog('SSE connection error, reconnecting in 3s...');
|
|
40
56
|
setTimeout(connect, 3000);
|
|
41
57
|
});
|
|
42
58
|
});
|
|
43
59
|
req.on('error', () => {
|
|
44
|
-
|
|
60
|
+
blogLog('SSE connect failed, retrying in 3s...');
|
|
45
61
|
setTimeout(connect, 3000);
|
|
46
62
|
});
|
|
47
63
|
}
|
|
@@ -88,33 +104,76 @@ function handleSSEEvent(event, data, options) {
|
|
|
88
104
|
injectAndMarkDelivered(id, text, tmuxPane);
|
|
89
105
|
}
|
|
90
106
|
}
|
|
107
|
+
// TIOCSTI Python script — injects each character of `text` + newline into the
|
|
108
|
+
// controlling terminal's input queue via ioctl(TIOCSTI). Passed as a CLI arg
|
|
109
|
+
// (not inline) so special characters in the message are never shell-interpreted.
|
|
110
|
+
const TIOCSTI_SCRIPT = `
|
|
111
|
+
import fcntl, sys
|
|
112
|
+
TIOCSTI = 0x5412
|
|
113
|
+
with open('/dev/tty', 'rb+', buffering=0) as tty:
|
|
114
|
+
for c in (sys.argv[1] + '\\n').encode():
|
|
115
|
+
fcntl.ioctl(tty, TIOCSTI, bytes([c]))
|
|
116
|
+
`.trim();
|
|
117
|
+
function injectViaTiocsti(text) {
|
|
118
|
+
try {
|
|
119
|
+
execFileSync('python3', ['-c', TIOCSTI_SCRIPT, text], { timeout: 5000 });
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
// Capture stderr from the Python process (where the real error lives)
|
|
124
|
+
const stderr = err?.stderr?.toString().trim() || '';
|
|
125
|
+
const stdout = err?.stdout?.toString().trim() || '';
|
|
126
|
+
const msg = err?.message || String(err);
|
|
127
|
+
return [msg, stderr, stdout].filter(Boolean).join(' | ');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
91
130
|
function injectAndMarkDelivered(id, text, tmuxPane) {
|
|
92
131
|
const db = getDb();
|
|
93
132
|
db.prepare('UPDATE agent_messages SET delivered = 1 WHERE id = ?').run(id);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
execFileSync('tmux', ['send-keys', '-t', tmuxPane, 'Enter'], { stdio: 'ignore', timeout: 5000 });
|
|
103
|
-
}
|
|
104
|
-
catch { /* pane may have closed */ }
|
|
105
|
-
// Second Enter after another delay — catches CLIs that need an extra nudge
|
|
106
|
-
// after bracketed paste ends (e.g. long messages that trigger paste mode)
|
|
133
|
+
if (tmuxPane) {
|
|
134
|
+
try {
|
|
135
|
+
// Send text literally (-l) so special chars are safe and tmux doesn't interpret them
|
|
136
|
+
execFileSync('tmux', ['send-keys', '-t', tmuxPane, '-l', text], { stdio: 'ignore', timeout: 5000 });
|
|
137
|
+
// Delay before Enter — gives the CLI time to fully process the bracketed paste.
|
|
138
|
+
// Without this, Codex (and similar TUIs) may only show partial text because
|
|
139
|
+
// the Enter arrives inside the paste bracket and gets swallowed.
|
|
107
140
|
setTimeout(() => {
|
|
108
141
|
try {
|
|
109
142
|
execFileSync('tmux', ['send-keys', '-t', tmuxPane, 'Enter'], { stdio: 'ignore', timeout: 5000 });
|
|
110
143
|
}
|
|
111
144
|
catch { /* pane may have closed */ }
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
// Second Enter after another delay — catches CLIs that need an extra nudge
|
|
146
|
+
// after bracketed paste ends (e.g. long messages that trigger paste mode)
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
try {
|
|
149
|
+
execFileSync('tmux', ['send-keys', '-t', tmuxPane, 'Enter'], { stdio: 'ignore', timeout: 5000 });
|
|
150
|
+
}
|
|
151
|
+
catch { /* pane may have closed */ }
|
|
152
|
+
}, 500);
|
|
153
|
+
}, 300);
|
|
154
|
+
blogLog(`delivered message ${id} to tmux pane ${tmuxPane}`);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
blogLog(`tmux send-keys failed for message ${id}: ${err}`);
|
|
158
|
+
}
|
|
115
159
|
}
|
|
116
|
-
|
|
117
|
-
|
|
160
|
+
else {
|
|
161
|
+
// Non-tmux: try TIOCSTI to inject text into the controlling terminal's input
|
|
162
|
+
// queue. TIOCSTI (0x5412) works when the caller shares the controlling terminal
|
|
163
|
+
// with the target process — which is true here since the MCP server is a child
|
|
164
|
+
// of Claude Code and inherits its tty session.
|
|
165
|
+
// Falls back to stderr if python3 is unavailable or the ioctl is denied.
|
|
166
|
+
const tiocError = injectViaTiocsti(text);
|
|
167
|
+
if (tiocError !== null) {
|
|
168
|
+
blogLog(`TIOCSTI failed for message ${id}: ${tiocError}`);
|
|
169
|
+
// Last-resort visual: at least make the message visible in the terminal output.
|
|
170
|
+
// The tool-response piggyback in index.ts will also deliver it on the next call.
|
|
171
|
+
process.stderr.write(`\n\x1b[33m[INBOX #${id}]: ${text}\x1b[0m\n`);
|
|
172
|
+
blogLog(`message ${id} visible in stderr, will appear in next tool response`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
blogLog(`delivered message ${id} via TIOCSTI`);
|
|
176
|
+
}
|
|
118
177
|
}
|
|
119
178
|
}
|
|
120
179
|
function deliverUndelivered(options) {
|
|
@@ -150,7 +209,8 @@ function deliverUndelivered(options) {
|
|
|
150
209
|
*/
|
|
151
210
|
export function startTmuxBridge(options) {
|
|
152
211
|
startSSEListener(options);
|
|
153
|
-
|
|
212
|
+
const pane = options.tmuxPane;
|
|
213
|
+
blogLog(`bridge active for agent "${options.getAgentName()}"${pane ? ` on tmux pane ${pane}` : ' (TIOCSTI mode — no tmux pane)'}`);
|
|
154
214
|
return () => {
|
|
155
215
|
// SSE connection will close when process exits
|
|
156
216
|
};
|
|
@@ -41,6 +41,21 @@ export function registerAgentInboxTools(server) {
|
|
|
41
41
|
myAgentName = doRegister({ customName: params.name });
|
|
42
42
|
return successResponse({ name: myAgentName, message: `Registered as "${myAgentName}"` });
|
|
43
43
|
});
|
|
44
|
+
server.tool('delete_agent', 'Delete a stale or disconnected agent entry from the registry. Only disconnected agents can be deleted. Use this to clean up ghost entries left by crashed or renamed sessions.', {
|
|
45
|
+
name: z.string().describe('Name of the agent entry to delete'),
|
|
46
|
+
}, { destructiveHint: true }, async (params) => {
|
|
47
|
+
const db = getDb();
|
|
48
|
+
const agent = getAgent(params.name);
|
|
49
|
+
if (!agent)
|
|
50
|
+
return errorResponse(`Agent "${params.name}" not found`, 'NOT_FOUND');
|
|
51
|
+
if (agent.status === 'connected') {
|
|
52
|
+
return errorResponse(`Agent "${params.name}" is currently connected. Disconnect it first or wait for it to go offline.`, 'CONFLICT');
|
|
53
|
+
}
|
|
54
|
+
db.prepare('DELETE FROM agent_registry WHERE name = ?').run(params.name);
|
|
55
|
+
broadcastChange('agent', 'agent_deleted', { name: params.name });
|
|
56
|
+
logActivity('agent_deleted', `Agent "${params.name}" deleted from registry`, { entityType: 'agent' });
|
|
57
|
+
return successResponse({ deleted: true, name: params.name });
|
|
58
|
+
});
|
|
44
59
|
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.', {
|
|
45
60
|
project_id: z.number().describe('Project ID to attach the question to'),
|
|
46
61
|
question: z.string().describe('The question to ask the user'),
|
package/dist/types.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ export declare const ActivityAction: z.ZodEnum<{
|
|
|
68
68
|
agent_connected: "agent_connected";
|
|
69
69
|
agent_disconnected: "agent_disconnected";
|
|
70
70
|
agent_renamed: "agent_renamed";
|
|
71
|
+
agent_deleted: "agent_deleted";
|
|
71
72
|
terminal_send_keys: "terminal_send_keys";
|
|
72
73
|
terminal_captured: "terminal_captured";
|
|
73
74
|
compaction_summary: "compaction_summary";
|
|
@@ -75,7 +76,7 @@ export declare const ActivityAction: z.ZodEnum<{
|
|
|
75
76
|
}>;
|
|
76
77
|
export type ActivityAction = z.infer<typeof ActivityAction>;
|
|
77
78
|
export declare const VALID_TRANSITIONS: Record<TaskStatus, TaskStatus[]>;
|
|
78
|
-
export type ErrorCode = 'NOT_FOUND' | 'INVALID_TRANSITION' | 'VALIDATION_ERROR' | 'CYCLE_DETECTED' | 'SESSION_ALREADY_ACTIVE' | 'NO_ACTIVE_SESSION' | 'ALREADY_ANSWERED';
|
|
79
|
+
export type ErrorCode = 'NOT_FOUND' | 'INVALID_TRANSITION' | 'VALIDATION_ERROR' | 'CYCLE_DETECTED' | 'SESSION_ALREADY_ACTIVE' | 'NO_ACTIVE_SESSION' | 'ALREADY_ANSWERED' | 'CONFLICT';
|
|
79
80
|
export declare const LinkSchema: z.ZodObject<{
|
|
80
81
|
label: z.ZodString;
|
|
81
82
|
url: z.ZodString;
|
package/dist/types.js
CHANGED
|
@@ -15,7 +15,7 @@ export const ActivityAction = z.enum([
|
|
|
15
15
|
'task_linked', 'task_unlinked', 'dependency_added', 'dependency_removed',
|
|
16
16
|
'link_added', 'tag_added', 'tag_removed', 'debug_log',
|
|
17
17
|
'agent_question', 'agent_question_answered', 'agent_broadcast',
|
|
18
|
-
'agent_connected', 'agent_disconnected', 'agent_renamed',
|
|
18
|
+
'agent_connected', 'agent_disconnected', 'agent_renamed', 'agent_deleted',
|
|
19
19
|
'terminal_send_keys', 'terminal_captured',
|
|
20
20
|
'compaction_summary', 'activity_compacted',
|
|
21
21
|
]);
|