@teamvibe/poller 0.8.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.
@@ -9,6 +9,12 @@ interface AuthConfig {
9
9
  sessionsTable: string;
10
10
  region: string;
11
11
  }
12
+ export declare class AuthHttpError extends Error {
13
+ readonly status: number;
14
+ constructor(status: number, _body?: string);
15
+ }
16
+ export declare class StartupConfigurationError extends Error {
17
+ }
12
18
  export declare function initAuth(apiUrl: string, token: string): Promise<void>;
13
19
  export declare function getCredentials(): AuthCredentials;
14
20
  export declare function getAuthConfig(): AuthConfig;
@@ -1,6 +1,17 @@
1
- import { POLLER_VERSION } from './config.js';
1
+ import { config, POLLER_VERSION } from './config.js';
2
2
  import { logger } from './logger.js';
3
3
  import { emitError } from './event-queue.js';
4
+ import { getBaseBrainCommitSha } from './base-brain-metadata.js';
5
+ export class AuthHttpError extends Error {
6
+ status;
7
+ constructor(status, _body) {
8
+ super(`Auth failed (${status})`);
9
+ this.status = status;
10
+ this.name = 'AuthHttpError';
11
+ }
12
+ }
13
+ export class StartupConfigurationError extends Error {
14
+ }
4
15
  let currentCredentials = null;
5
16
  let currentConfig = null;
6
17
  let currentPollerId = null;
@@ -13,13 +24,16 @@ const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 minutes (before 1hr expiry)
13
24
  const HEARTBEAT_INTERVAL_MS = 60 * 1000; // 1 minute
14
25
  async function sendHeartbeat(apiUrl, token, version) {
15
26
  try {
27
+ const baseBrainSha = await getBaseBrainCommitSha(config.BASE_BRAIN_PATH);
28
+ if (!baseBrainSha)
29
+ logger.warn('[Heartbeat] Could not read base-brain HEAD for pin readback');
16
30
  const response = await fetch(`${apiUrl}/heartbeat`, {
17
31
  method: 'POST',
18
32
  headers: {
19
33
  Authorization: `Bearer ${token}`,
20
34
  'Content-Type': 'application/json',
21
35
  },
22
- body: JSON.stringify({ version }),
36
+ body: JSON.stringify({ version, baseBrainSha, baseBrainBranch: config.BASE_BRAIN_BRANCH }),
23
37
  });
24
38
  if (!response.ok) {
25
39
  logger.warn(`Heartbeat failed (${response.status})`);
@@ -31,25 +45,56 @@ async function sendHeartbeat(apiUrl, token, version) {
31
45
  emitError('auth', `Heartbeat error: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
32
46
  }
33
47
  }
34
- async function fetchCredentials(apiUrl, token) {
48
+ async function fetchCredentials(apiUrl, token, signal) {
35
49
  const response = await fetch(`${apiUrl}/auth`, {
36
50
  method: 'POST',
51
+ signal,
37
52
  headers: {
38
53
  Authorization: `Bearer ${token}`,
39
54
  'Content-Type': 'application/json',
40
55
  },
41
56
  });
42
57
  if (!response.ok) {
43
- const body = await response.text();
44
- throw new Error(`Auth failed (${response.status}): ${body}`);
58
+ throw new AuthHttpError(response.status);
59
+ }
60
+ try {
61
+ return await response.json();
45
62
  }
46
- return response.json();
63
+ catch {
64
+ throw new StartupConfigurationError('Auth response is not valid JSON');
65
+ }
66
+ }
67
+ function validateStartupAuthResponse(auth) {
68
+ const value = auth;
69
+ const authRecord = value;
70
+ const credentials = authRecord?.credentials;
71
+ const authConfig = authRecord?.config;
72
+ if (typeof authRecord?.pollerId !== 'string' || !authRecord.pollerId ||
73
+ typeof credentials?.accessKeyId !== 'string' || !credentials.accessKeyId ||
74
+ typeof credentials?.secretAccessKey !== 'string' || !credentials.secretAccessKey ||
75
+ typeof credentials?.sessionToken !== 'string' || !credentials.sessionToken ||
76
+ typeof credentials?.expiration !== 'string' || !credentials.expiration ||
77
+ typeof authConfig?.sqsQueueUrl !== 'string' || !authConfig.sqsQueueUrl ||
78
+ typeof authConfig?.sessionsTable !== 'string' || !authConfig.sessionsTable ||
79
+ typeof authConfig?.region !== 'string' || !authConfig.region)
80
+ throw new StartupConfigurationError('Auth response is missing or has malformed credentials/config');
47
81
  }
48
82
  export async function initAuth(apiUrl, token) {
83
+ if (!token.trim())
84
+ throw new StartupConfigurationError('TEAMVIBE_POLLER_TOKEN is missing or empty');
85
+ try {
86
+ const url = new URL(apiUrl);
87
+ if (!['http:', 'https:'].includes(url.protocol))
88
+ throw new Error('unsupported protocol');
89
+ }
90
+ catch {
91
+ throw new StartupConfigurationError('TEAMVIBE_API_URL is malformed');
92
+ }
49
93
  logger.info('Authenticating with TeamVibe API...');
50
94
  storedApiUrl = apiUrl;
51
95
  storedToken = token;
52
- const auth = await fetchCredentials(apiUrl, token);
96
+ const auth = await fetchCredentials(apiUrl, token, AbortSignal.timeout(15_000));
97
+ validateStartupAuthResponse(auth);
53
98
  currentCredentials = auth.credentials;
54
99
  currentConfig = auth.config;
55
100
  currentPollerId = auth.pollerId;
@@ -3,6 +3,7 @@ import { join } from 'path';
3
3
  import { execFile } from 'child_process';
4
4
  import { promisify } from 'util';
5
5
  const execFileAsync = promisify(execFile);
6
+ const GIT_HEAD_TIMEOUT_MS = 2_000;
6
7
  /**
7
8
  * Read the current HEAD commit SHA of a git repo. Returns null on any error
8
9
  * (not a git repo, git missing, dir missing, etc.) so callers can attach a
@@ -10,7 +11,11 @@ const execFileAsync = promisify(execFile);
10
11
  */
11
12
  export async function getBaseBrainCommitSha(repoDir) {
12
13
  try {
13
- const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoDir });
14
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
15
+ cwd: repoDir,
16
+ timeout: GIT_HEAD_TIMEOUT_MS,
17
+ killSignal: 'SIGKILL',
18
+ });
14
19
  const sha = stdout.trim();
15
20
  return sha || null;
16
21
  }
@@ -6,6 +6,7 @@ import { join, basename } from 'path';
6
6
  import { config } from './config.js';
7
7
  import { logger } from './logger.js';
8
8
  import { emitError } from './event-queue.js';
9
+ import { startupFailureDetail } from './startup-auth.js';
9
10
  const execAsync = promisify(exec);
10
11
  // Clean env for git commands:
11
12
  // - npm/npx sets GIT_ASKPASS which prevents credential helpers from working
@@ -257,7 +258,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
257
258
  catch (error) {
258
259
  // Pre-check itself failed (e.g. brainDir not a git repo). Fail-safe to skip;
259
260
  // the next iteration will retry.
260
- const errorMessage = error instanceof Error ? error.message : String(error);
261
+ const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
261
262
  logger.warn(`Brain ${brainId} pre-check failed, skipping update: ${errorMessage}`);
262
263
  return;
263
264
  }
@@ -271,7 +272,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
271
272
  logger.info(`Brain ${brainId} updated successfully`);
272
273
  }
273
274
  catch (error) {
274
- const errorMessage = error instanceof Error ? error.message : String(error);
275
+ const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
275
276
  logger.error(`Failed to update brain ${brainId}: ${errorMessage}`);
276
277
  emitError('brain', `Failed to update brain ${brainId}: ${errorMessage}`, { context: { brainId, brainDir } });
277
278
  }
@@ -283,7 +284,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
283
284
  export async function ensureBaseBrain() {
284
285
  const brainDir = config.BASE_BRAIN_PATH;
285
286
  if (!existsSync(brainDir)) {
286
- logger.info(`Cloning base brain from ${config.BASE_BRAIN_REPO} (branch: ${config.BASE_BRAIN_BRANCH})...`);
287
+ logger.info(`Cloning base brain (branch: ${config.BASE_BRAIN_BRANCH})...`);
287
288
  await gitExec(`git clone --recurse-submodules --branch ${config.BASE_BRAIN_BRANCH} ${config.BASE_BRAIN_REPO} ${brainDir}`);
288
289
  lastUpdateTimes.set('__base_brain__', Date.now());
289
290
  logger.info('Base brain cloned successfully');
@@ -702,22 +702,13 @@ export function assistantCalledTool(event, toolName) {
702
702
  // Status text is a one-line UI label, not a chat message — cap it well short
703
703
  // of a full sentence.
704
704
  const TYPING_STATUS_MAX_LEN = 100;
705
- // tv#369: decide what the typing indicator should show for a given
706
- // stream-json event. Prefers Claude's own narration text over a generic
707
- // placeholder — surfaces the same "what I'm doing / found" signal the CLI's
708
- // own transcript view already shows. Falls back to a per-tool canned status
709
- // (tv#291) when the assistant event carries a tool_use block but no
710
- // narration text, and to 'Thinking...' when neither is present. Reads
711
- // tool_use blocks from `event.message.content[]` rather than a top-level
712
- // `event.type === 'tool_use'` check — see assistantCalledTool's comment
713
- // above for why the latter never matches the real stream shape.
714
- //
715
- // `assistantText`, when passed, is the caller's already-extracted
716
- // extractAssistantText(event) result (the stdout handler computes it a few
717
- // lines above for the end-of-turn guard) — avoids re-parsing the same event
718
- // twice per line. Falls back to extracting it internally so the function
719
- // stays a self-contained, directly-testable unit.
720
- export function resolveTypingStatus(event, assistantText) {
705
+ function oneLineStatus(text) {
706
+ const oneLine = text.replace(/\s+/g, ' ').trim();
707
+ return oneLine.length > TYPING_STATUS_MAX_LEN
708
+ ? oneLine.slice(0, TYPING_STATUS_MAX_LEN - 1) + '…'
709
+ : oneLine;
710
+ }
711
+ function resolveTypingStatusDetail(event, assistantText) {
721
712
  if (event?.type !== 'assistant' || !Array.isArray(event.message?.content)) {
722
713
  return null;
723
714
  }
@@ -729,19 +720,22 @@ export function resolveTypingStatus(event, assistantText) {
729
720
  if (text) {
730
721
  // Collapse newlines/whitespace runs so a multi-line narration doesn't
731
722
  // land a literal `\n` in the one-line status surface.
732
- const oneLine = text.replace(/\s+/g, ' ').trim();
733
- return oneLine.length > TYPING_STATUS_MAX_LEN
734
- ? oneLine.slice(0, TYPING_STATUS_MAX_LEN - 1) + '…'
735
- : oneLine;
723
+ return { text: oneLineStatus(text), source: 'comment' };
736
724
  }
737
725
  for (const block of event.message.content) {
738
726
  if (block?.type !== 'tool_use' || typeof block.name !== 'string')
739
727
  continue;
740
728
  const status = getStatusForTool(block.name);
741
- if (status)
742
- return status;
729
+ if (status) {
730
+ const safeToolName = /^(?:Bash|Read|Edit|Write|WebFetch|WebSearch|Grep|Glob|Agent)$/.test(block.name)
731
+ ? block.name : 'other';
732
+ return { text: oneLineStatus(status), source: `tool:${safeToolName}` };
733
+ }
743
734
  }
744
- return 'Thinking...';
735
+ return { text: 'Thinking...', source: 'default' };
736
+ }
737
+ export function resolveTypingStatus(event, assistantText) {
738
+ return resolveTypingStatusDetail(event, assistantText)?.text ?? null;
745
739
  }
746
740
  async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
747
741
  const slackContext = msg.response_context.slack;
@@ -1050,9 +1044,9 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1050
1044
  }
1051
1045
  // Auto-update typing indicator status based on tool usage (tv#369, tv#291)
1052
1046
  if (typingIndicator) {
1053
- const status = resolveTypingStatus(event, assistantText);
1047
+ const status = resolveTypingStatusDetail(event, assistantText);
1054
1048
  if (status) {
1055
- typingIndicator.updateStatus(status);
1049
+ typingIndicator.updateStatus(status.text, status.source);
1056
1050
  }
1057
1051
  }
1058
1052
  }
@@ -1143,7 +1137,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1143
1137
  });
1144
1138
  // Surface a visible status so the user isn't left with a stale/generic
1145
1139
  // indicator during long silent stretches (e.g. context compaction).
1146
- typingIndicator?.updateStatus('Still working on a long response...');
1140
+ typingIndicator?.updateStatus('Still working on a long response...', 'default');
1147
1141
  idleWarned = true;
1148
1142
  }
1149
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,3 +1,11 @@
1
+ /** Best-effort runtime identity, sampled when each report is posted. */
2
+ export declare function collectReportProvenance(brainPath: string): Promise<{
3
+ pollerId: string | null;
4
+ pollerVersion: string;
5
+ baseBrainSha: string | null;
6
+ baseBrainBranch: string;
7
+ brainSha: string | null;
8
+ }>;
1
9
  /**
2
10
  * Scan for JSON maintenance reports in the brain's reports/ directory
3
11
  * and POST each to the /events endpoint. Fire-and-forget — never throws.
@@ -3,10 +3,29 @@ import { join } from 'path';
3
3
  import { execFile } from 'child_process';
4
4
  import { promisify } from 'util';
5
5
  import { config, POLLER_VERSION } from './config.js';
6
+ import { getPollerId } from './auth-provider.js';
6
7
  import { logger } from './logger.js';
7
8
  import { emitError } from './event-queue.js';
8
9
  import { getBaseBrainCommitSha, getBaseBrainAgeHours, } from './base-brain-metadata.js';
9
10
  const execFileAsync = promisify(execFile);
11
+ /** Best-effort runtime identity, sampled when each report is posted. */
12
+ export async function collectReportProvenance(brainPath) {
13
+ const [baseBrainSha, brainSha] = await Promise.all([
14
+ getBaseBrainCommitSha(config.BASE_BRAIN_PATH),
15
+ getBaseBrainCommitSha(brainPath),
16
+ ]);
17
+ if (!baseBrainSha)
18
+ logger.warn('[MaintenanceReporter] Could not read base-brain HEAD for report provenance');
19
+ if (!brainSha)
20
+ logger.warn('[MaintenanceReporter] Could not read channel-brain HEAD for report provenance');
21
+ return {
22
+ pollerId: getPollerId(),
23
+ pollerVersion: POLLER_VERSION,
24
+ baseBrainSha,
25
+ baseBrainBranch: config.BASE_BRAIN_BRANCH,
26
+ brainSha,
27
+ };
28
+ }
10
29
  const OPERATION_TO_EVENT_TYPE = {
11
30
  consolidation: 'maintenance.consolidation',
12
31
  reflection: 'maintenance.reflection',
@@ -131,7 +150,7 @@ export async function postMaintenanceReports(brainPath, channelId) {
131
150
  const payload = {
132
151
  eventType,
133
152
  channelId,
134
- pollerVersion: POLLER_VERSION,
153
+ ...(await collectReportProvenance(brainPath)),
135
154
  baseBrainCommitSha,
136
155
  baseBrainAgeHours,
137
156
  metadata: report,
package/dist/poller.js CHANGED
@@ -9,6 +9,7 @@ import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } fr
9
9
  import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
10
10
  import { postMaintenanceReports } from './maintenance-reporter.js';
11
11
  import { initAuth, stopRefresh, getPollerId } from './auth-provider.js';
12
+ import { retryStartupAuth, retryStartupOperation, startupFailureDetail } from './startup-auth.js';
12
13
  import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './maintenance-cron.js';
13
14
  import { bootstrapTrackedBrains } from './brains-bootstrap.js';
14
15
  import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
@@ -870,8 +871,9 @@ export async function startPoller() {
870
871
  logger.info(`TeamVibe Poller v${POLLER_VERSION} starting...`);
871
872
  logger.info(` Max concurrent: ${config.MAX_CONCURRENT_SESSIONS}`);
872
873
  logger.info(` Brains path: ${config.BRAINS_PATH}`);
873
- process.on('SIGINT', () => shutdown('SIGINT'));
874
- process.on('SIGTERM', () => shutdown('SIGTERM'));
874
+ const startupAbort = new AbortController();
875
+ process.on('SIGINT', () => { startupAbort.abort(); void shutdown('SIGINT'); });
876
+ process.on('SIGTERM', () => { startupAbort.abort(); void shutdown('SIGTERM'); });
875
877
  // Last-resort handlers so a hard crash produces one tagged ERROR line (+ a
876
878
  // best-effort event flush) instead of an untagged default Node stack trace —
877
879
  // these are exactly the failures that never reach /events on their own.
@@ -899,7 +901,15 @@ export async function startPoller() {
899
901
  });
900
902
  // Token-based auth: fetch credentials before starting
901
903
  if (config.TEAMVIBE_API_URL && config.TEAMVIBE_POLLER_TOKEN) {
902
- await initAuth(config.TEAMVIBE_API_URL, config.TEAMVIBE_POLLER_TOKEN);
904
+ try {
905
+ const ready = await retryStartupAuth(() => initAuth(config.TEAMVIBE_API_URL, config.TEAMVIBE_POLLER_TOKEN), startupAbort.signal, logger, Math.random, [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
906
+ if (!ready)
907
+ return;
908
+ }
909
+ catch (error) {
910
+ logger.error(`Startup authentication failed permanently: ${startupFailureDetail(error, 'StartupAuth', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO])}`);
911
+ process.exit(1);
912
+ }
903
913
  const pollerId = getPollerId();
904
914
  if (pollerId) {
905
915
  setLogContext({ pollerId });
@@ -910,9 +920,19 @@ export async function startPoller() {
910
920
  logger.info(` Queue: ${config.SQS_QUEUE_URL}`);
911
921
  logger.info(` Sessions table: ${config.SESSIONS_TABLE}`);
912
922
  }
923
+ if (startupAbort.signal.aborted)
924
+ return;
913
925
  startEventQueue();
914
926
  await ensureDirectories();
915
- await ensureBaseBrain();
927
+ try {
928
+ const ready = await retryStartupOperation('BaseBrain', ensureBaseBrain, startupAbort.signal, logger, Math.random, [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
929
+ if (!ready)
930
+ return;
931
+ }
932
+ catch (error) {
933
+ logger.error(`Startup base brain failed permanently: ${startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO])}`);
934
+ process.exit(1);
935
+ }
916
936
  reconcileBgTasksOnBoot();
917
937
  if (config.IDLE_RESTART_ENABLED) {
918
938
  // Defaults off (tv#317 follow-up, DevGuru review): a silent disabled gate and an
@@ -7,11 +7,13 @@ 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
+ type TypingStatusSource = 'comment' | 'default' | `tool:${string}`;
10
11
  export interface TypingIndicator {
11
12
  /** Update the displayed status text */
12
- updateStatus: (newStatus: string) => void;
13
+ updateStatus: (newStatus: string, source?: TypingStatusSource) => void;
13
14
  /** Stop the typing indicator and clear the status */
14
15
  stop: () => void;
15
16
  }
16
17
  export declare function startTypingIndicator(msg: TeamVibeQueueMessage, statusText?: string): TypingIndicator;
17
18
  export declare function removeReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
19
+ export {};
@@ -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) {
@@ -94,22 +97,87 @@ export async function setThreadStatus(msg, status) {
94
97
  return;
95
98
  const { channel, thread_ts } = msg.response_context.slack;
96
99
  const slack = getSlackClient(msg.teamvibe.botToken);
100
+ const safeStatus = neutralizeSlackMarkup(status);
101
+ const payload = {
102
+ channel_id: channel,
103
+ thread_ts,
104
+ status: safeStatus,
105
+ };
97
106
  try {
98
107
  await slack.apiCall('assistant.threads.setStatus', {
99
- channel_id: channel,
100
- thread_ts,
101
- status,
108
+ ...payload,
109
+ ...(safeStatus ? { loading_messages: [truncateLoadingMessage(safeStatus)] } : {}),
102
110
  });
103
111
  }
104
112
  catch (error) {
105
- logger.warn(`Failed to set thread status: ${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
+ }
127
+ // API errors may echo request fields; status can contain private narration.
128
+ logger.warn('Failed to set thread status');
106
129
  emitError('slack', `Failed to set thread status`, { severity: 'warn' });
107
130
  }
108
131
  }
109
- export function startTypingIndicator(msg, statusText = 'is thinking...') {
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
+ }
145
+ function neutralizeSlackMarkup(text) {
146
+ return text.replace(/[<>&]/g, (character) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[character]);
147
+ }
148
+ export function startTypingIndicator(msg, statusText = 'Thinking...') {
110
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;
154
+ let stopped = false;
155
+ const logStatus = (status, source) => {
156
+ if (!status)
157
+ logger.info('[Typing] status cleared');
158
+ else
159
+ logger.info(`[Typing] status -> ${source} (${status.length} chars)`);
160
+ };
111
161
  // Set initial status
162
+ logStatus(currentStatus, currentSource);
112
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
+ };
113
181
  // Keepalive every 3 seconds (Slack clears after ~2 min timeout)
114
182
  let failures = 0;
115
183
  const interval = setInterval(async () => {
@@ -125,14 +193,39 @@ export function startTypingIndicator(msg, statusText = 'is thinking...') {
125
193
  }
126
194
  }, 3000);
127
195
  return {
128
- updateStatus: (newStatus) => {
129
- if (newStatus !== currentStatus) {
130
- currentStatus = newStatus;
131
- setThreadStatus(msg, currentStatus);
196
+ updateStatus: (newStatus, source = 'comment') => {
197
+ if (stopped)
198
+ return;
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;
132
217
  }
218
+ cancelHold();
219
+ showStatus(newStatus, source);
133
220
  },
134
221
  stop: () => {
222
+ if (stopped)
223
+ return;
224
+ stopped = true;
225
+ cancelHold();
135
226
  clearInterval(interval);
227
+ if (currentStatus)
228
+ logStatus('', 'default');
136
229
  setThreadStatus(msg, '');
137
230
  },
138
231
  };
@@ -0,0 +1,15 @@
1
+ export interface StartupAuthLogger {
2
+ warn(message: string): void;
3
+ error(message: string): void;
4
+ }
5
+ export declare class UnknownStartupFailureError extends Error {
6
+ readonly attempts: number;
7
+ constructor(attempts: number);
8
+ }
9
+ /** The only formatter used for startup retry and terminal failure logs. */
10
+ export declare function redactStartupText(text: string, configuredTokens?: readonly string[]): string;
11
+ export declare function startupFailureDetail(error: unknown, operation: string, configuredTokens?: readonly string[]): string;
12
+ export declare function isPermanentStartupFailure(error: unknown): boolean;
13
+ /** Returns false when shutdown cancels startup. Permanent errors propagate to the caller. */
14
+ export declare function retryStartupOperation(operation: string, run: () => Promise<void>, signal: AbortSignal, log: StartupAuthLogger, random?: () => number, configuredTokens?: readonly string[]): Promise<boolean>;
15
+ export declare function retryStartupAuth(authenticate: () => Promise<void>, signal: AbortSignal, log: StartupAuthLogger, random?: () => number, configuredTokens?: readonly string[]): Promise<boolean>;
@@ -0,0 +1,137 @@
1
+ import { AuthHttpError, StartupConfigurationError } from './auth-provider.js';
2
+ export class UnknownStartupFailureError extends Error {
3
+ attempts;
4
+ constructor(attempts) {
5
+ super(`unclassified startup failure after ${attempts} attempts`);
6
+ this.attempts = attempts;
7
+ this.name = 'UnknownStartupFailureError';
8
+ }
9
+ }
10
+ const NETWORK_CODES = new Set([
11
+ 'EAI_AGAIN', 'ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT',
12
+ 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
13
+ ]);
14
+ function isTransient(error) {
15
+ if (error instanceof AuthHttpError)
16
+ return error.status === 429 || error.status >= 500;
17
+ if (error instanceof StartupConfigurationError)
18
+ return false;
19
+ if (!(error instanceof Error))
20
+ return false;
21
+ if (error.name === 'AbortError' || error.name === 'TimeoutError')
22
+ return true;
23
+ if (error instanceof TypeError && /fetch failed|network|timeout/i.test(error.message))
24
+ return true;
25
+ if (/could not resolve host|could not resolve hostname|temporary failure in name resolution|failed to connect|connection (timed out|refused|reset)|network is unreachable|(?:HTTP|response|returned error:?)\s*(?:429|5\d\d)\b/i.test(error.message))
26
+ return true;
27
+ let cause = error;
28
+ for (let depth = 0; depth < 4 && cause && typeof cause === 'object'; depth++) {
29
+ const code = cause.code;
30
+ if (typeof code === 'string' && NETWORK_CODES.has(code))
31
+ return true;
32
+ cause = cause.cause;
33
+ }
34
+ return false;
35
+ }
36
+ /** The only formatter used for startup retry and terminal failure logs. */
37
+ export function redactStartupText(text, configuredTokens = []) {
38
+ let safe = text
39
+ .replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
40
+ .replace(/\b(?:Authorization\s*:\s*)?Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
41
+ .replace(/\bx-access-token\s*:\s*[^\s/@,;]+/gi, 'x-access-token:[REDACTED]');
42
+ for (const token of configuredTokens) {
43
+ if (token)
44
+ safe = safe.split(token).join('[REDACTED]');
45
+ }
46
+ return safe;
47
+ }
48
+ export function startupFailureDetail(error, operation, configuredTokens = []) {
49
+ let detail;
50
+ if (error instanceof AuthHttpError)
51
+ detail = `HTTP ${error.status}`;
52
+ else if (error instanceof StartupConfigurationError)
53
+ detail = 'invalid startup configuration';
54
+ else if (error instanceof UnknownStartupFailureError)
55
+ detail = error.message;
56
+ else if (operation === 'BaseBrain') {
57
+ const code = error && typeof error === 'object' ? error.code : undefined;
58
+ const exit = typeof code === 'number' ? code : 'unknown';
59
+ const message = error instanceof Error ? error.message : '';
60
+ const category = /could not resolve|name resolution|ENOTFOUND|EAI_AGAIN/i.test(message) ? 'DNS failure'
61
+ : /timed out|timeout|ETIMEDOUT/i.test(message) ? 'connection timeout'
62
+ : /failed to connect|connection refused|ECONNREFUSED/i.test(message) ? 'connection failure'
63
+ : /authentication failed|permission denied|403|401/i.test(message) ? 'authentication failure'
64
+ : 'git failure';
65
+ detail = `${category} (exit ${exit})`;
66
+ }
67
+ else {
68
+ let code = error;
69
+ for (let depth = 0; depth < 4 && code && typeof code === 'object'; depth++) {
70
+ const value = code.code;
71
+ if (typeof value === 'string' && NETWORK_CODES.has(value)) {
72
+ detail = value;
73
+ break;
74
+ }
75
+ code = code.cause;
76
+ }
77
+ detail ??= error instanceof TypeError && /fetch failed/i.test(error.message) ? 'fetch failed'
78
+ : error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name) ? error.name
79
+ : 'unclassified error';
80
+ }
81
+ return redactStartupText(detail ?? 'unclassified error', configuredTokens);
82
+ }
83
+ export function isPermanentStartupFailure(error) {
84
+ return error instanceof StartupConfigurationError ||
85
+ (error instanceof AuthHttpError && error.status !== 429 && error.status < 500);
86
+ }
87
+ function waitForBackoff(ms, signal) {
88
+ if (signal.aborted)
89
+ return Promise.resolve(false);
90
+ return new Promise((resolve) => {
91
+ const finish = (ready) => {
92
+ clearTimeout(timer);
93
+ signal.removeEventListener('abort', onAbort);
94
+ resolve(ready);
95
+ };
96
+ const onAbort = () => finish(false);
97
+ const timer = setTimeout(() => finish(true), ms);
98
+ signal.addEventListener('abort', onAbort, { once: true });
99
+ if (signal.aborted)
100
+ onAbort();
101
+ });
102
+ }
103
+ /** Returns false when shutdown cancels startup. Permanent errors propagate to the caller. */
104
+ export async function retryStartupOperation(operation, run, signal, log, random = Math.random, configuredTokens = []) {
105
+ let attempt = 0;
106
+ while (!signal.aborted) {
107
+ attempt++;
108
+ try {
109
+ await run();
110
+ return !signal.aborted;
111
+ }
112
+ catch (error) {
113
+ if (signal.aborted)
114
+ return false;
115
+ if (isPermanentStartupFailure(error))
116
+ throw error;
117
+ const transient = isTransient(error);
118
+ if (!transient && attempt >= 5) {
119
+ log.error(`[${operation}] attempt ${attempt} failed: ${startupFailureDetail(error, operation, configuredTokens)}; unclassified failure limit reached`);
120
+ throw new UnknownStartupFailureError(attempt);
121
+ }
122
+ const baseMs = Math.min(1000 * 2 ** Math.min(attempt - 1, 6), 60_000);
123
+ const delayMs = Math.min(60_000, Math.round(baseMs * (1 + 0.2 * random())));
124
+ const message = `[${operation}] attempt ${attempt} failed: ${startupFailureDetail(error, operation, configuredTokens)}; retrying in ${delayMs}ms`;
125
+ if (transient)
126
+ log.warn(message);
127
+ else
128
+ log.error(message);
129
+ if (!await waitForBackoff(delayMs, signal))
130
+ return false;
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+ export function retryStartupAuth(authenticate, signal, log, random = Math.random, configuredTokens = []) {
136
+ return retryStartupOperation('StartupAuth', authenticate, signal, log, random, configuredTokens);
137
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.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.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.1",
3
+ "version": "0.8.2-rc.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {