@dalmasonto/taskflow-mcp 1.0.33 → 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.
@@ -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;
@@ -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
- if (tmuxTarget) {
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
  }
@@ -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
- const body = JSON.parse(await readBody(req));
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
- const body = JSON.parse(await readBody(req));
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
- const body = JSON.parse(await readBody(req));
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
- const body = JSON.parse(await readBody(req));
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
- const body = JSON.parse(await readBody(req));
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
- const body = JSON.parse(await readBody(req));
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
- const body = JSON.parse(await readBody(req));
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
- const body = JSON.parse(await readBody(req));
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 fetch(`${RELAY_URL}/commands/pending`, {
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 && clients.size > 0) {
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
- fetch(`http://localhost:${activePort}/api/broadcast`, {
801
+ fetchWithRetry('broadcast relay', `http://localhost:${activePort}/api/broadcast`, {
744
802
  method: 'POST',
745
803
  headers: { 'Content-Type': 'application/json' },
746
804
  body,
@@ -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
- tmuxPane: string;
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.
@@ -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
- console.error('[bridge] SSE connection closed, reconnecting in 3s...');
51
+ blogLog('SSE connection closed, reconnecting in 3s...');
36
52
  setTimeout(connect, 3000);
37
53
  });
38
54
  res.on('error', () => {
39
- console.error('[bridge] SSE connection error, reconnecting in 3s...');
55
+ blogLog('SSE connection error, reconnecting in 3s...');
40
56
  setTimeout(connect, 3000);
41
57
  });
42
58
  });
43
59
  req.on('error', () => {
44
- console.error('[bridge] SSE connect failed, retrying in 3s...');
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
- try {
95
- // Send text literally (-l) so special chars are safe and tmux doesn't interpret them
96
- execFileSync('tmux', ['send-keys', '-t', tmuxPane, '-l', text], { stdio: 'ignore', timeout: 5000 });
97
- // Delay before Enter gives the CLI time to fully process the bracketed paste.
98
- // Without this, Codex (and similar TUIs) may only show partial text because
99
- // the Enter arrives inside the paste bracket and gets swallowed.
100
- setTimeout(() => {
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
- }, 500);
113
- }, 300);
114
- console.error(`[bridge] delivered message ${id} to tmux pane ${tmuxPane}`);
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
- catch (err) {
117
- console.error(`[bridge] tmux send-keys failed for message ${id}:`, err);
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
- console.error(`[bridge] tmux bridge active for agent "${options.getAgentName()}" on pane ${options.tmuxPane}`);
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
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalmasonto/taskflow-mcp",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
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",