@teamvibe/poller 0.8.0 → 0.8.2-rc.1

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');
@@ -212,6 +212,7 @@ export interface SpawnStdinPlan {
212
212
  export declare function buildSpawnStdinPlan(prompt: string, inlineImages: TeamVibeQueueMessage['inlineImages'], now?: number): SpawnStdinPlan;
213
213
  export declare function extractAssistantText(event: any): string | undefined;
214
214
  export declare function assistantCalledTool(event: any, toolName: string): boolean;
215
+ export declare function resolveTypingStatus(event: any, assistantText?: string): string | null;
215
216
  /**
216
217
  * Decides whether a spawn attempt should report its session ID back to the
217
218
  * caller (poller.ts) for persistence to DynamoDB via updateSessionId().
@@ -699,6 +699,44 @@ export function assistantCalledTool(event, toolName) {
699
699
  }
700
700
  return event.message.content.some((c) => c?.type === 'tool_use' && c.name === toolName);
701
701
  }
702
+ // Status text is a one-line UI label, not a chat message — cap it well short
703
+ // of a full sentence.
704
+ const TYPING_STATUS_MAX_LEN = 100;
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) {
712
+ if (event?.type !== 'assistant' || !Array.isArray(event.message?.content)) {
713
+ return null;
714
+ }
715
+ // The caller clears the indicator when this tool sends a reply. Do not
716
+ // restore a status from that same event, even if it also contains narration.
717
+ if (assistantCalledTool(event, 'mcp__slack__send_message'))
718
+ return null;
719
+ const text = assistantText ?? extractAssistantText(event);
720
+ if (text) {
721
+ // Collapse newlines/whitespace runs so a multi-line narration doesn't
722
+ // land a literal `\n` in the one-line status surface.
723
+ return { text: oneLineStatus(text), source: 'comment' };
724
+ }
725
+ for (const block of event.message.content) {
726
+ if (block?.type !== 'tool_use' || typeof block.name !== 'string')
727
+ continue;
728
+ const status = getStatusForTool(block.name);
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
+ }
734
+ }
735
+ return { text: 'Thinking...', source: 'default' };
736
+ }
737
+ export function resolveTypingStatus(event, assistantText) {
738
+ return resolveTypingStatusDetail(event, assistantText)?.text ?? null;
739
+ }
702
740
  async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
703
741
  const slackContext = msg.response_context.slack;
704
742
  const isCronMessage = msg.source === 'cron';
@@ -1004,18 +1042,11 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1004
1042
  scheduleProgressNotifications(progressNotificationMarks(minutes));
1005
1043
  }
1006
1044
  }
1007
- // Auto-update typing indicator status based on tool usage
1045
+ // Auto-update typing indicator status based on tool usage (tv#369, tv#291)
1008
1046
  if (typingIndicator) {
1009
- if (event.type === 'tool_use' && event.name) {
1010
- const status = getStatusForTool(event.name);
1011
- if (status !== null && status !== '') {
1012
- typingIndicator.updateStatus(status);
1013
- }
1014
- // When status is '' (e.g. set_status tool), don't override — Claude is managing it
1015
- }
1016
- else if (event.type === 'assistant') {
1017
- // Reset to "Thinking..." when Claude is reasoning between tool calls
1018
- typingIndicator.updateStatus('Thinking...');
1047
+ const status = resolveTypingStatusDetail(event, assistantText);
1048
+ if (status) {
1049
+ typingIndicator.updateStatus(status.text, status.source);
1019
1050
  }
1020
1051
  }
1021
1052
  }
@@ -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';
@@ -219,6 +220,14 @@ export function describeFailureReason(killedByPoller, exitCode) {
219
220
  // content an agent forgot to send via send_message) still delivers as
220
221
  // before, so this can't silently drop a forgotten reply.
221
222
  export const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = 'NO_FURTHER_RESPONSE_NEEDED';
223
+ // tv#358 (revised): the sentinel agents emit and the guard matches is
224
+ // unchanged — still the exact ALL_CAPS literal above, still an exact-match
225
+ // suppression. What changes is purely cosmetic: poller-brain#428's leak
226
+ // failure mode (a sentence written before the sentinel breaks the exact
227
+ // match, so the whole thing delivers as-is) showed the raw literal reads as
228
+ // jarring when it does surface. Swap it for a quieter display token
229
+ // wherever it appears in text that's actually being delivered.
230
+ const END_OF_TURN_SENTINEL_DISPLAY = '[silent]';
222
231
  export function endOfTurnFallbackText(hasSlackContext, result) {
223
232
  if (!hasSlackContext || !result.success || result.sawSendMessageCall) {
224
233
  return undefined;
@@ -227,7 +236,7 @@ export function endOfTurnFallbackText(hasSlackContext, result) {
227
236
  if (!text || text === END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL) {
228
237
  return undefined;
229
238
  }
230
- return text;
239
+ return text.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join(END_OF_TURN_SENTINEL_DISPLAY);
231
240
  }
232
241
  export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
233
242
  if (activeLoops.has(threadId)) {
@@ -862,8 +871,9 @@ export async function startPoller() {
862
871
  logger.info(`TeamVibe Poller v${POLLER_VERSION} starting...`);
863
872
  logger.info(` Max concurrent: ${config.MAX_CONCURRENT_SESSIONS}`);
864
873
  logger.info(` Brains path: ${config.BRAINS_PATH}`);
865
- process.on('SIGINT', () => shutdown('SIGINT'));
866
- 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'); });
867
877
  // Last-resort handlers so a hard crash produces one tagged ERROR line (+ a
868
878
  // best-effort event flush) instead of an untagged default Node stack trace —
869
879
  // these are exactly the failures that never reach /events on their own.
@@ -891,7 +901,15 @@ export async function startPoller() {
891
901
  });
892
902
  // Token-based auth: fetch credentials before starting
893
903
  if (config.TEAMVIBE_API_URL && config.TEAMVIBE_POLLER_TOKEN) {
894
- 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
+ }
895
913
  const pollerId = getPollerId();
896
914
  if (pollerId) {
897
915
  setLogContext({ pollerId });
@@ -902,9 +920,19 @@ export async function startPoller() {
902
920
  logger.info(` Queue: ${config.SQS_QUEUE_URL}`);
903
921
  logger.info(` Sessions table: ${config.SESSIONS_TABLE}`);
904
922
  }
923
+ if (startupAbort.signal.aborted)
924
+ return;
905
925
  startEventQueue();
906
926
  await ensureDirectories();
907
- 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
+ }
908
936
  reconcileBgTasksOnBoot();
909
937
  if (config.IDLE_RESTART_ENABLED) {
910
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 {};
@@ -94,21 +94,35 @@ export async function setThreadStatus(msg, status) {
94
94
  return;
95
95
  const { channel, thread_ts } = msg.response_context.slack;
96
96
  const slack = getSlackClient(msg.teamvibe.botToken);
97
+ const safeStatus = neutralizeSlackMarkup(status);
97
98
  try {
98
99
  await slack.apiCall('assistant.threads.setStatus', {
99
100
  channel_id: channel,
100
101
  thread_ts,
101
- status,
102
+ status: safeStatus,
103
+ ...(safeStatus ? { loading_messages: [safeStatus] } : {}),
102
104
  });
103
105
  }
104
- catch (error) {
105
- logger.warn(`Failed to set thread status: ${error}`);
106
+ catch {
107
+ // API errors may echo request fields; status can contain private narration.
108
+ logger.warn('Failed to set thread status');
106
109
  emitError('slack', `Failed to set thread status`, { severity: 'warn' });
107
110
  }
108
111
  }
109
- export function startTypingIndicator(msg, statusText = 'is thinking...') {
112
+ function neutralizeSlackMarkup(text) {
113
+ return text.replace(/[<>&]/g, (character) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[character]);
114
+ }
115
+ export function startTypingIndicator(msg, statusText = 'Thinking...') {
110
116
  let currentStatus = statusText;
117
+ let stopped = false;
118
+ const logStatus = (status, source) => {
119
+ if (!status)
120
+ logger.info('[Typing] status cleared');
121
+ else
122
+ logger.info(`[Typing] status -> ${source} (${status.length} chars)`);
123
+ };
111
124
  // Set initial status
125
+ logStatus(currentStatus, statusText === 'Thinking...' ? 'default' : 'comment');
112
126
  setThreadStatus(msg, currentStatus);
113
127
  // Keepalive every 3 seconds (Slack clears after ~2 min timeout)
114
128
  let failures = 0;
@@ -125,14 +139,22 @@ export function startTypingIndicator(msg, statusText = 'is thinking...') {
125
139
  }
126
140
  }, 3000);
127
141
  return {
128
- updateStatus: (newStatus) => {
142
+ updateStatus: (newStatus, source = 'comment') => {
143
+ if (stopped)
144
+ return;
129
145
  if (newStatus !== currentStatus) {
130
146
  currentStatus = newStatus;
147
+ logStatus(currentStatus, source);
131
148
  setThreadStatus(msg, currentStatus);
132
149
  }
133
150
  },
134
151
  stop: () => {
152
+ if (stopped)
153
+ return;
154
+ stopped = true;
135
155
  clearInterval(interval);
156
+ if (currentStatus)
157
+ logStatus('', 'default');
136
158
  setThreadStatus(msg, '');
137
159
  },
138
160
  };
@@ -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
+ }