@teamvibe/poller 0.1.29 → 0.1.31

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.
@@ -1,12 +1,13 @@
1
1
  import type { TeamVibeQueueMessage } from './types.js';
2
2
  import { type SessionLogger } from './logger.js';
3
+ import type { TypingIndicator } from './slack-client.js';
3
4
  export interface SpawnResult {
4
5
  success: boolean;
5
6
  output: string;
6
7
  exitCode: number | null;
7
8
  error?: string;
8
9
  }
9
- export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void): Promise<SpawnResult & {
10
+ export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void, typingIndicator?: TypingIndicator): Promise<SpawnResult & {
10
11
  newSessionId?: string;
11
12
  }>;
12
13
  export declare function getActiveProcessCount(): number;
@@ -4,6 +4,45 @@ import { WebClient } from '@slack/web-api';
4
4
  import { config } from './config.js';
5
5
  import { logger } from './logger.js';
6
6
  import { getBaseBrainPath } from './brain-manager.js';
7
+ /**
8
+ * Maps tool names (or prefixes) to human-readable status messages.
9
+ * More specific patterns are checked first.
10
+ */
11
+ const TOOL_STATUS_MAP = [
12
+ // Slack MCP tools
13
+ { pattern: 'mcp__slack__search_messages', status: 'Searching Slack messages...' },
14
+ { pattern: 'mcp__slack__read_thread', status: 'Reading thread...' },
15
+ { pattern: 'mcp__slack__send_message', status: 'Sending message...' },
16
+ { pattern: 'mcp__slack__set_status', status: '' }, // Don't override Claude's own status
17
+ { pattern: 'mcp__slack__upload_snippet', status: 'Uploading snippet...' },
18
+ { pattern: /^mcp__slack__/, status: 'Working with Slack...' },
19
+ // Web tools
20
+ { pattern: 'WebSearch', status: 'Searching the web...' },
21
+ { pattern: 'WebFetch', status: 'Reading a webpage...' },
22
+ // File/code tools
23
+ { pattern: 'Read', status: 'Reading files...' },
24
+ { pattern: 'Grep', status: 'Searching code...' },
25
+ { pattern: 'Glob', status: 'Finding files...' },
26
+ { pattern: 'Edit', status: 'Editing code...' },
27
+ { pattern: 'Write', status: 'Writing code...' },
28
+ { pattern: 'Bash', status: 'Running a command...' },
29
+ { pattern: 'Agent', status: 'Delegating to a sub-agent...' },
30
+ // Generic MCP fallback
31
+ { pattern: /^mcp__/, status: 'Using an integration...' },
32
+ ];
33
+ function getStatusForTool(toolName) {
34
+ for (const { pattern, status } of TOOL_STATUS_MAP) {
35
+ if (typeof pattern === 'string') {
36
+ if (toolName === pattern)
37
+ return status;
38
+ }
39
+ else {
40
+ if (pattern.test(toolName))
41
+ return status;
42
+ }
43
+ }
44
+ return null;
45
+ }
7
46
  // Per-token Slack client cache for thread context
8
47
  const slackClients = new Map();
9
48
  function getSlackClient(botToken) {
@@ -126,7 +165,7 @@ function formatStreamEvent(event) {
126
165
  return null;
127
166
  }
128
167
  }
129
- async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent) {
168
+ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
130
169
  const slackContext = msg.response_context.slack;
131
170
  const isCronMessage = msg.source === 'cron';
132
171
  if (!slackContext && !isCronMessage) {
@@ -231,6 +270,20 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
231
270
  event.name === 'mcp__slack__send_message') {
232
271
  onMessageSent();
233
272
  }
273
+ // Auto-update typing indicator status based on tool usage
274
+ if (typingIndicator) {
275
+ if (event.type === 'tool_use' && event.name) {
276
+ const status = getStatusForTool(event.name);
277
+ if (status !== null && status !== '') {
278
+ typingIndicator.updateStatus(status);
279
+ }
280
+ // When status is '' (e.g. set_status tool), don't override — Claude is managing it
281
+ }
282
+ else if (event.type === 'assistant') {
283
+ // Reset to "Thinking..." when Claude is reasoning between tool calls
284
+ typingIndicator.updateStatus('Thinking...');
285
+ }
286
+ }
234
287
  }
235
288
  catch {
236
289
  const truncated = line.length > 500 ? line.slice(0, 500) + '...' : line;
@@ -243,8 +296,11 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
243
296
  stderr += chunk;
244
297
  sessionLog.claude('stderr', chunk.trimEnd());
245
298
  });
299
+ let timedOut = false;
300
+ const timeoutMinutes = Math.round(config.CLAUDE_TIMEOUT_MS / 60000);
246
301
  const timeout = setTimeout(() => {
247
- logger.error(`Claude Code process timed out after ${config.CLAUDE_TIMEOUT_MS}ms`);
302
+ timedOut = true;
303
+ logger.error(`Claude Code process timed out after ${timeoutMinutes} minutes`);
248
304
  proc.kill('SIGTERM');
249
305
  setTimeout(() => proc.kill('SIGKILL'), 5000);
250
306
  }, config.CLAUDE_TIMEOUT_MS);
@@ -256,7 +312,10 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
256
312
  output: stdout,
257
313
  exitCode: code,
258
314
  };
259
- if (code !== 0) {
315
+ if (timedOut) {
316
+ result.error = `Session timed out after ${timeoutMinutes} minutes. The task was still running when the time limit was reached. Consider breaking complex tasks into smaller steps.`;
317
+ }
318
+ else if (code !== 0) {
260
319
  result.error = stderr || `Process exited with code ${code}`;
261
320
  }
262
321
  resolve(result);
@@ -273,20 +332,20 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
273
332
  });
274
333
  });
275
334
  }
276
- export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent) {
277
- const result = await runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage, lastMessageTs, onMessageSent);
335
+ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
336
+ const result = await runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
278
337
  // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
279
338
  let retryResult = result;
280
339
  if (!result.success && sessionId && !isFirstMessage) {
281
340
  sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
282
- retryResult = await runClaudeCode(msg, sessionLog, cwd, sessionId, true, lastMessageTs, onMessageSent);
341
+ retryResult = await runClaudeCode(msg, sessionLog, cwd, sessionId, true, lastMessageTs, onMessageSent, typingIndicator);
283
342
  }
284
343
  // If session ID is "already in use" (stale lock file), retry with a fresh session ID
285
344
  if (!retryResult.success && sessionId && retryResult.error?.includes('already in use')) {
286
345
  sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
287
346
  const { randomUUID } = await import('crypto');
288
347
  const freshId = randomUUID();
289
- const freshResult = await runClaudeCode(msg, sessionLog, cwd, freshId, true, lastMessageTs, onMessageSent);
348
+ const freshResult = await runClaudeCode(msg, sessionLog, cwd, freshId, true, lastMessageTs, onMessageSent, typingIndicator);
290
349
  return { ...freshResult, newSessionId: freshId };
291
350
  }
292
351
  return retryResult;
package/dist/config.js CHANGED
@@ -19,8 +19,8 @@ const configSchema = z.object({
19
19
  POLL_WAIT_TIME_SECONDS: z.coerce.number().default(20),
20
20
  VISIBILITY_TIMEOUT_SECONDS: z.coerce.number().default(60),
21
21
  HEARTBEAT_INTERVAL_MS: z.coerce.number().default(40000), // 40 seconds
22
- CLAUDE_TIMEOUT_MS: z.coerce.number().default(1800000), // 30 minutes
23
- STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(2100000), // 35 minutes
22
+ CLAUDE_TIMEOUT_MS: z.coerce.number().default(3600000), // 60 minutes
23
+ STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(3900000), // 65 minutes
24
24
  // Paths
25
25
  TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
26
26
  BRAINS_PATH: z.string().default(''),
package/dist/poller.js CHANGED
@@ -125,14 +125,14 @@ async function processMessage(received) {
125
125
  processingMessages.add(messageId);
126
126
  const heartbeat = startHeartbeat(receiptHandle, sessionLog);
127
127
  // Start typing indicator
128
- const stopTyping = hasSlackContext
128
+ const typing = hasSlackContext
129
129
  ? startTypingIndicator(queueMessage)
130
130
  : undefined;
131
131
  try {
132
132
  // Refresh base brain if cooldown has elapsed (5 min default)
133
133
  await ensureBaseBrain();
134
- const result = await spawnClaudeCode(queueMessage, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => stopTyping?.());
135
- stopTyping?.();
134
+ const result = await spawnClaudeCode(queueMessage, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
135
+ typing?.stop();
136
136
  // If Claude generated a new session ID (stale lock recovery), persist it
137
137
  if (result.newSessionId && lockToken) {
138
138
  await updateSessionId(threadId, lockToken, result.newSessionId);
@@ -151,8 +151,10 @@ async function processMessage(received) {
151
151
  else {
152
152
  sessionLog.error(`Claude Code failed: ${result.error}`);
153
153
  if (hasSlackContext) {
154
- await sendSlackError(queueMessage, result.error || `Process exited with code ${result.exitCode}`);
155
- await addReaction(queueMessage, 'x');
154
+ const errorMessage = result.error || `Process exited with code ${result.exitCode}`;
155
+ const isTimeout = errorMessage.includes('timed out');
156
+ await sendSlackError(queueMessage, errorMessage);
157
+ await addReaction(queueMessage, isTimeout ? 'hourglass' : 'x');
156
158
  }
157
159
  if (lockToken) {
158
160
  await releaseSessionLock(threadId, lockToken, 'idle');
@@ -162,7 +164,7 @@ async function processMessage(received) {
162
164
  sessionLog.info('Message processed and deleted');
163
165
  }
164
166
  catch (error) {
165
- stopTyping?.();
167
+ typing?.stop();
166
168
  sessionLog.error(`Error processing message: ${error instanceof Error ? error.message : error}`);
167
169
  if (lockToken) {
168
170
  try {
@@ -7,5 +7,11 @@ export declare function sendSlackMessage(msg: TeamVibeQueueMessage, text: string
7
7
  export declare function sendSlackError(msg: TeamVibeQueueMessage, error: string): Promise<void>;
8
8
  export declare function addReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
9
9
  export declare function setThreadStatus(msg: TeamVibeQueueMessage, status: string): Promise<void>;
10
- export declare function startTypingIndicator(msg: TeamVibeQueueMessage, statusText?: string): () => void;
10
+ export interface TypingIndicator {
11
+ /** Update the displayed status text */
12
+ updateStatus: (newStatus: string) => void;
13
+ /** Stop the typing indicator and clear the status */
14
+ stop: () => void;
15
+ }
16
+ export declare function startTypingIndicator(msg: TeamVibeQueueMessage, statusText?: string): TypingIndicator;
11
17
  export declare function removeReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
@@ -60,10 +60,14 @@ export async function sendSlackError(msg, error) {
60
60
  return;
61
61
  const { channel, thread_ts } = msg.response_context.slack;
62
62
  const slack = getSlackClient(msg.teamvibe.botToken);
63
+ const isTimeout = error.includes('timed out');
64
+ const text = isTimeout
65
+ ? `:warning: ${error}`
66
+ : `Error processing your request:\n\`\`\`\n${error}\n\`\`\``;
63
67
  await slack.chat.postMessage({
64
68
  channel,
65
69
  thread_ts,
66
- text: `Error processing your request:\n\`\`\`\n${error}\n\`\`\``,
70
+ text,
67
71
  });
68
72
  }
69
73
  export async function addReaction(msg, emoji) {
@@ -99,13 +103,14 @@ export async function setThreadStatus(msg, status) {
99
103
  }
100
104
  }
101
105
  export function startTypingIndicator(msg, statusText = 'is thinking...') {
106
+ let currentStatus = statusText;
102
107
  // Set initial status
103
- setThreadStatus(msg, statusText);
108
+ setThreadStatus(msg, currentStatus);
104
109
  // Keepalive every 3 seconds (Slack clears after ~2 min timeout)
105
110
  let failures = 0;
106
111
  const interval = setInterval(async () => {
107
112
  try {
108
- await setThreadStatus(msg, statusText);
113
+ await setThreadStatus(msg, currentStatus);
109
114
  failures = 0;
110
115
  }
111
116
  catch {
@@ -115,10 +120,17 @@ export function startTypingIndicator(msg, statusText = 'is thinking...') {
115
120
  }
116
121
  }
117
122
  }, 3000);
118
- // Return cleanup function
119
- return () => {
120
- clearInterval(interval);
121
- setThreadStatus(msg, '');
123
+ return {
124
+ updateStatus: (newStatus) => {
125
+ if (newStatus !== currentStatus) {
126
+ currentStatus = newStatus;
127
+ setThreadStatus(msg, currentStatus);
128
+ }
129
+ },
130
+ stop: () => {
131
+ clearInterval(interval);
132
+ setThreadStatus(msg, '');
133
+ },
122
134
  };
123
135
  }
124
136
  export async function removeReaction(msg, emoji) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {