instar 0.4.2 → 0.4.3

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.
@@ -113,6 +113,27 @@ function wireTelegramCallbacks(telegram, sessionManager, state) {
113
113
  telegram.onIsSessionAlive = (sessionName) => {
114
114
  return sessionManager.isSessionAlive(sessionName);
115
115
  };
116
+ // Stall verification — check if session has recent output activity
117
+ telegram.onIsSessionActive = async (sessionName) => {
118
+ const output = sessionManager.captureOutput(sessionName, 20);
119
+ if (!output)
120
+ return false;
121
+ const lines = output.trim().split('\n').slice(-15);
122
+ // Look for signs of Claude Code activity in recent output
123
+ const activePatterns = [
124
+ /\bRead\b|\bWrite\b|\bEdit\b|\bBash\b|\bGrep\b|\bGlob\b/, // Tool names
125
+ /⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏/, // Spinner characters
126
+ /\d+\s*tokens?/i, // Token counts
127
+ /Sent \d+ chars/, // Telegram reply confirmation
128
+ ];
129
+ for (const line of lines) {
130
+ for (const pattern of activePatterns) {
131
+ if (pattern.test(line))
132
+ return true;
133
+ }
134
+ }
135
+ return false;
136
+ };
116
137
  }
117
138
  /**
118
139
  * Wire up Telegram message routing: topic messages → Claude sessions.
@@ -65,6 +65,7 @@ export declare class TelegramAdapter implements MessagingAdapter {
65
65
  alive: boolean;
66
66
  }>) | null;
67
67
  onIsSessionAlive: ((tmuxSession: string) => boolean) | null;
68
+ onIsSessionActive: ((tmuxSession: string) => Promise<boolean>) | null;
68
69
  constructor(config: TelegramConfig, stateDir: string);
69
70
  start(): Promise<void>;
70
71
  stop(): Promise<void>;
@@ -37,6 +37,7 @@ export class TelegramAdapter {
37
37
  onRestartSession = null;
38
38
  onListSessions = null;
39
39
  onIsSessionAlive = null;
40
+ onIsSessionActive = null;
40
41
  constructor(config, stateDir) {
41
42
  this.config = config;
42
43
  this.stateDir = stateDir;
@@ -237,7 +238,7 @@ export class TelegramAdapter {
237
238
  }
238
239
  }
239
240
  }
240
- checkForStalls() {
241
+ async checkForStalls() {
241
242
  const stallMinutes = this.config.stallTimeoutMinutes ?? 5;
242
243
  const stallThresholdMs = stallMinutes * 60 * 1000;
243
244
  const now = Date.now();
@@ -250,6 +251,21 @@ export class TelegramAdapter {
250
251
  const alive = this.onIsSessionAlive
251
252
  ? this.onIsSessionAlive(pending.sessionName)
252
253
  : true; // assume alive if no checker
254
+ // If alive, verify the session is truly stalled (not just responding through a different path)
255
+ if (alive && this.onIsSessionActive) {
256
+ try {
257
+ const active = await this.onIsSessionActive(pending.sessionName);
258
+ if (active) {
259
+ // Session is producing output — false alarm, clear it
260
+ console.log(`[telegram] Session "${pending.sessionName}" verified active, clearing stall`);
261
+ this.pendingMessages.delete(key);
262
+ continue;
263
+ }
264
+ }
265
+ catch {
266
+ // Verifier failed — fall through to alert
267
+ }
268
+ }
253
269
  pending.alerted = true;
254
270
  const status = alive ? 'running but not responding' : 'no longer running';
255
271
  const minutesAgo = Math.round((now - pending.injectedAt) / 60000);
@@ -278,20 +294,36 @@ export class TelegramAdapter {
278
294
  * Download a file from Telegram by file_id.
279
295
  */
280
296
  async downloadFile(fileId, destPath) {
281
- const fileInfo = await this.apiCall('getFile', { file_id: fileId });
282
- const fileUrl = `https://api.telegram.org/file/bot${this.config.token}/${fileInfo.file_path}`;
283
- const controller = new AbortController();
284
- const timer = setTimeout(() => controller.abort(), 60_000);
285
- try {
286
- const response = await fetch(fileUrl, { signal: controller.signal });
287
- if (!response.ok)
288
- throw new Error(`Download failed: ${response.status}`);
289
- const buffer = Buffer.from(await response.arrayBuffer());
290
- fs.writeFileSync(destPath, buffer);
291
- }
292
- finally {
293
- clearTimeout(timer);
297
+ const maxRetries = 3;
298
+ let lastError;
299
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
300
+ try {
301
+ const fileInfo = await this.apiCall('getFile', { file_id: fileId });
302
+ const fileUrl = `https://api.telegram.org/file/bot${this.config.token}/${fileInfo.file_path}`;
303
+ const controller = new AbortController();
304
+ const timer = setTimeout(() => controller.abort(), 60_000);
305
+ try {
306
+ const response = await fetch(fileUrl, { signal: controller.signal });
307
+ if (!response.ok)
308
+ throw new Error(`Download failed: ${response.status}`);
309
+ const buffer = Buffer.from(await response.arrayBuffer());
310
+ fs.writeFileSync(destPath, buffer);
311
+ return; // Success
312
+ }
313
+ finally {
314
+ clearTimeout(timer);
315
+ }
316
+ }
317
+ catch (err) {
318
+ lastError = err instanceof Error ? err : new Error(String(err));
319
+ if (attempt < maxRetries) {
320
+ const delay = attempt * 1000;
321
+ console.warn(`[telegram] File download attempt ${attempt}/${maxRetries} failed, retrying in ${delay}ms...`);
322
+ await new Promise(resolve => setTimeout(resolve, delay));
323
+ }
324
+ }
294
325
  }
326
+ throw lastError;
295
327
  }
296
328
  /**
297
329
  * Resolve voice transcription provider from config or environment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",