@teamvibe/poller 0.8.2-rc.1 → 0.8.2-rc.2

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.
@@ -1137,7 +1137,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1137
1137
  });
1138
1138
  // Surface a visible status so the user isn't left with a stale/generic
1139
1139
  // indicator during long silent stretches (e.g. context compaction).
1140
- typingIndicator?.updateStatus('Still working on a long response...');
1140
+ typingIndicator?.updateStatus('Still working on a long response...', 'default');
1141
1141
  idleWarned = true;
1142
1142
  }
1143
1143
  }, 30000); // Check every 30 seconds
package/dist/config.d.ts CHANGED
@@ -18,6 +18,7 @@ declare const configSchema: z.ZodObject<{
18
18
  IDLE_WATCHDOG_COMPACTION_GRACE_MS: z.ZodDefault<z.ZodNumber>;
19
19
  NETWORK_ERROR_KILL_MS: z.ZodDefault<z.ZodNumber>;
20
20
  MAX_CONSECUTIVE_NET_FAILURES: z.ZodDefault<z.ZodNumber>;
21
+ TYPING_COMMENT_MIN_HOLD_MS: z.ZodDefault<z.ZodNumber>;
21
22
  TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
22
23
  BRAINS_PATH: z.ZodDefault<z.ZodString>;
23
24
  DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
@@ -60,6 +61,7 @@ declare const configSchema: z.ZodObject<{
60
61
  IDLE_WATCHDOG_COMPACTION_GRACE_MS: number;
61
62
  NETWORK_ERROR_KILL_MS: number;
62
63
  MAX_CONSECUTIVE_NET_FAILURES: number;
64
+ TYPING_COMMENT_MIN_HOLD_MS: number;
63
65
  TEAMVIBE_DATA_DIR: string;
64
66
  BRAINS_PATH: string;
65
67
  DEFAULT_BRAIN_PATH: string;
@@ -108,6 +110,7 @@ declare const configSchema: z.ZodObject<{
108
110
  IDLE_WATCHDOG_COMPACTION_GRACE_MS?: number | undefined;
109
111
  NETWORK_ERROR_KILL_MS?: number | undefined;
110
112
  MAX_CONSECUTIVE_NET_FAILURES?: number | undefined;
113
+ TYPING_COMMENT_MIN_HOLD_MS?: number | undefined;
111
114
  TEAMVIBE_DATA_DIR?: string | undefined;
112
115
  BRAINS_PATH?: string | undefined;
113
116
  DEFAULT_BRAIN_PATH?: string | undefined;
@@ -153,6 +156,7 @@ export declare const config: {
153
156
  IDLE_WATCHDOG_COMPACTION_GRACE_MS: number;
154
157
  NETWORK_ERROR_KILL_MS: number;
155
158
  MAX_CONSECUTIVE_NET_FAILURES: number;
159
+ TYPING_COMMENT_MIN_HOLD_MS: number;
156
160
  TEAMVIBE_DATA_DIR: string;
157
161
  BRAINS_PATH: string;
158
162
  DEFAULT_BRAIN_PATH: string;
package/dist/config.js CHANGED
@@ -27,6 +27,7 @@ const configSchema = z.object({
27
27
  IDLE_WATCHDOG_COMPACTION_GRACE_MS: z.coerce.number().default(300000), // 5 minutes — fixed grace from compact_boundary arrival, not a large speculative ceiling (compact_boundary only proves compaction STARTED, never that it finishes in bounded time)
28
28
  NETWORK_ERROR_KILL_MS: z.coerce.number().default(120000), // Per-session: kill stuck session after 2 min of network errors
29
29
  MAX_CONSECUTIVE_NET_FAILURES: z.coerce.number().default(5), // Poll loop: DNS flush at N, exit(1) at 2×N consecutive failures
30
+ TYPING_COMMENT_MIN_HOLD_MS: z.coerce.number().default(4500), // Minimum time to display agent narration before a canned status
30
31
  // Paths
31
32
  TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
32
33
  BRAINS_PATH: z.string().default(''),
@@ -1,10 +1,13 @@
1
1
  import { WebClient } from '@slack/web-api';
2
2
  import { logger } from './logger.js';
3
3
  import { emitError } from './event-queue.js';
4
+ import { config } from './config.js';
4
5
  // Cache user info to avoid repeated API calls
5
6
  const userCache = new Map();
6
7
  // Per-token Slack client cache
7
8
  const slackClients = new Map();
9
+ // Slack: "must be less than 51 characters [json-pointer:/loading_messages/0]"
10
+ const SLACK_LOADING_MESSAGE_MAX = 50;
8
11
  function getSlackClient(botToken) {
9
12
  let client = slackClients.get(botToken);
10
13
  if (!client) {
@@ -95,25 +98,59 @@ export async function setThreadStatus(msg, status) {
95
98
  const { channel, thread_ts } = msg.response_context.slack;
96
99
  const slack = getSlackClient(msg.teamvibe.botToken);
97
100
  const safeStatus = neutralizeSlackMarkup(status);
101
+ const payload = {
102
+ channel_id: channel,
103
+ thread_ts,
104
+ status: safeStatus,
105
+ };
98
106
  try {
99
107
  await slack.apiCall('assistant.threads.setStatus', {
100
- channel_id: channel,
101
- thread_ts,
102
- status: safeStatus,
103
- ...(safeStatus ? { loading_messages: [safeStatus] } : {}),
108
+ ...payload,
109
+ ...(safeStatus ? { loading_messages: [truncateLoadingMessage(safeStatus)] } : {}),
104
110
  });
105
111
  }
106
- catch {
112
+ catch (error) {
113
+ const messages = error?.data?.response_metadata?.messages;
114
+ const details = [error?.message, ...(Array.isArray(messages) ? messages : [])].join(' ');
115
+ if (safeStatus && error?.data?.error === 'invalid_arguments' && details.includes('loading_messages')) {
116
+ // API errors may echo request fields; never log the error or status.
117
+ logger.warn('Slack rejected loading messages; retrying thread status');
118
+ try {
119
+ await slack.apiCall('assistant.threads.setStatus', payload);
120
+ return;
121
+ }
122
+ catch {
123
+ emitError('slack', 'Failed to set thread status', { severity: 'warn' });
124
+ return;
125
+ }
126
+ }
107
127
  // API errors may echo request fields; status can contain private narration.
108
128
  logger.warn('Failed to set thread status');
109
129
  emitError('slack', `Failed to set thread status`, { severity: 'warn' });
110
130
  }
111
131
  }
132
+ function truncateLoadingMessage(text) {
133
+ // Treat escaped markup as whole tokens so truncation never leaves a partial entity.
134
+ const tokens = text.match(/&(?:lt|gt|amp);|[\s\S]/gu) ?? [];
135
+ if (text.length <= SLACK_LOADING_MESSAGE_MAX)
136
+ return text;
137
+ let result = '';
138
+ for (const token of tokens) {
139
+ if (result.length + token.length >= SLACK_LOADING_MESSAGE_MAX)
140
+ break;
141
+ result += token;
142
+ }
143
+ return result + '…';
144
+ }
112
145
  function neutralizeSlackMarkup(text) {
113
146
  return text.replace(/[<>&]/g, (character) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[character]);
114
147
  }
115
148
  export function startTypingIndicator(msg, statusText = 'Thinking...') {
116
149
  let currentStatus = statusText;
150
+ let currentSource = statusText === 'Thinking...' ? 'default' : 'comment';
151
+ let commentShownAt = currentSource === 'comment' ? Date.now() : 0;
152
+ let pendingCanned = null;
153
+ let holdTimer;
117
154
  let stopped = false;
118
155
  const logStatus = (status, source) => {
119
156
  if (!status)
@@ -122,8 +159,25 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
122
159
  logger.info(`[Typing] status -> ${source} (${status.length} chars)`);
123
160
  };
124
161
  // Set initial status
125
- logStatus(currentStatus, statusText === 'Thinking...' ? 'default' : 'comment');
162
+ logStatus(currentStatus, currentSource);
126
163
  setThreadStatus(msg, currentStatus);
164
+ const cancelHold = () => {
165
+ if (holdTimer !== undefined)
166
+ clearTimeout(holdTimer);
167
+ holdTimer = undefined;
168
+ pendingCanned = null;
169
+ };
170
+ const showStatus = (text, source) => {
171
+ const changed = text !== currentStatus;
172
+ currentStatus = text;
173
+ currentSource = text ? source : 'default';
174
+ if (text && source === 'comment')
175
+ commentShownAt = Date.now();
176
+ if (changed) {
177
+ logStatus(text, source);
178
+ setThreadStatus(msg, text);
179
+ }
180
+ };
127
181
  // Keepalive every 3 seconds (Slack clears after ~2 min timeout)
128
182
  let failures = 0;
129
183
  const interval = setInterval(async () => {
@@ -142,16 +196,33 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
142
196
  updateStatus: (newStatus, source = 'comment') => {
143
197
  if (stopped)
144
198
  return;
145
- if (newStatus !== currentStatus) {
146
- currentStatus = newStatus;
147
- logStatus(currentStatus, source);
148
- setThreadStatus(msg, currentStatus);
199
+ if (!newStatus || source === 'comment') {
200
+ cancelHold();
201
+ showStatus(newStatus, source);
202
+ return;
203
+ }
204
+ const remainingHold = config.TYPING_COMMENT_MIN_HOLD_MS - (Date.now() - commentShownAt);
205
+ if (currentSource === 'comment' && remainingHold > 0) {
206
+ pendingCanned = { text: newStatus, source };
207
+ if (holdTimer === undefined) {
208
+ holdTimer = setTimeout(() => {
209
+ holdTimer = undefined;
210
+ const pending = pendingCanned;
211
+ pendingCanned = null;
212
+ if (!stopped && pending)
213
+ showStatus(pending.text, pending.source);
214
+ }, remainingHold);
215
+ }
216
+ return;
149
217
  }
218
+ cancelHold();
219
+ showStatus(newStatus, source);
150
220
  },
151
221
  stop: () => {
152
222
  if (stopped)
153
223
  return;
154
224
  stopped = true;
225
+ cancelHold();
155
226
  clearInterval(interval);
156
227
  if (currentStatus)
157
228
  logStatus('', 'default');
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.2-rc.1",
3
+ "version": "0.8.2-rc.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@teamvibe/poller",
9
- "version": "0.8.2-rc.1",
9
+ "version": "0.8.2-rc.2",
10
10
  "dependencies": {
11
11
  "@aws-sdk/client-dynamodb": "3.1108.0",
12
12
  "@aws-sdk/client-sqs": "3.1108.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.2-rc.1",
3
+ "version": "0.8.2-rc.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {