instar 0.7.32 → 0.7.34

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.
@@ -0,0 +1,11 @@
1
+ > Why do I have a folder named ".vercel" in my project?
2
+ The ".vercel" folder is created when you link a directory to a Vercel project.
3
+
4
+ > What does the "project.json" file contain?
5
+ The "project.json" file contains:
6
+ - The ID of the Vercel project that you linked ("projectId")
7
+ - The ID of the user or team your Vercel project is owned by ("orgId")
8
+
9
+ > Should I commit the ".vercel" folder?
10
+ No, you should not share the ".vercel" folder with anyone.
11
+ Upon creation, it will be automatically added to your ".gitignore" file.
@@ -0,0 +1 @@
1
+ {"projectId":"prj_evM5LcItYL3IAmw8zNvEPGrHeaya","orgId":"team_dHctwIDcV3X9ydapQlCPHFGI","projectName":"claude-agent-kit"}
package/dist/cli.js CHANGED
File without changes
@@ -32,6 +32,9 @@ import { PrivateViewer } from '../publishing/PrivateViewer.js';
32
32
  import { TunnelManager } from '../tunnel/TunnelManager.js';
33
33
  import { EvolutionManager } from '../core/EvolutionManager.js';
34
34
  import { QuotaTracker } from '../monitoring/QuotaTracker.js';
35
+ import { AccountSwitcher } from '../monitoring/AccountSwitcher.js';
36
+ import { QuotaNotifier } from '../monitoring/QuotaNotifier.js';
37
+ import { classifySessionDeath } from '../monitoring/QuotaExhaustionDetector.js';
35
38
  /**
36
39
  * Respawn a session for a topic, including thread history in the bootstrap.
37
40
  * This prevents "thread drift" where respawned sessions lose context.
@@ -90,7 +93,7 @@ async function respawnSessionForTopic(sessionManager, telegram, targetSession, t
90
93
  * Wire up Telegram session management callbacks.
91
94
  * These enable /interrupt, /restart, /sessions commands and stall detection.
92
95
  */
93
- function wireTelegramCallbacks(telegram, sessionManager, state) {
96
+ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, accountSwitcher, claudePath) {
94
97
  // /interrupt — send Escape key to a tmux session
95
98
  telegram.onInterruptSession = async (sessionName) => {
96
99
  try {
@@ -148,12 +151,165 @@ function wireTelegramCallbacks(telegram, sessionManager, state) {
148
151
  }
149
152
  return false;
150
153
  };
154
+ // /switch-account — swap active Claude Code account
155
+ if (accountSwitcher) {
156
+ telegram.onSwitchAccountRequest = async (target, replyTopicId) => {
157
+ try {
158
+ const result = await accountSwitcher.switchAccount(target);
159
+ await telegram.sendToTopic(replyTopicId, result.message);
160
+ }
161
+ catch (err) {
162
+ await telegram.sendToTopic(replyTopicId, `Account switch failed: ${err instanceof Error ? err.message : String(err)}`);
163
+ }
164
+ };
165
+ }
166
+ // /quota — show quota status
167
+ if (quotaTracker) {
168
+ telegram.onQuotaStatusRequest = async (replyTopicId) => {
169
+ try {
170
+ const quotaState = quotaTracker.getState();
171
+ if (!quotaState) {
172
+ await telegram.sendToTopic(replyTopicId, 'No quota data available.');
173
+ return;
174
+ }
175
+ const recommendation = quotaTracker.getRecommendation();
176
+ const lines = [
177
+ `Weekly: ${quotaState.usagePercent}%`,
178
+ quotaState.fiveHourPercent != null ? `5-Hour: ${quotaState.fiveHourPercent}%` : null,
179
+ `Recommendation: ${recommendation}`,
180
+ `Last updated: ${quotaState.lastUpdated}`,
181
+ ].filter(Boolean);
182
+ // Add account info if available
183
+ if (accountSwitcher) {
184
+ const statuses = accountSwitcher.getAccountStatuses();
185
+ if (statuses.length > 0) {
186
+ lines.push('', 'Accounts:');
187
+ for (const s of statuses) {
188
+ const marker = s.isActive ? '→ ' : ' ';
189
+ const stale = s.isStale ? ' (stale)' : '';
190
+ const expired = s.tokenExpired ? ' (token expired)' : '';
191
+ lines.push(`${marker}${s.name || s.email}: ${s.weeklyPercent}%${stale}${expired}`);
192
+ }
193
+ }
194
+ }
195
+ await telegram.sendToTopic(replyTopicId, lines.join('\n'));
196
+ }
197
+ catch (err) {
198
+ await telegram.sendToTopic(replyTopicId, `Failed to get quota: ${err instanceof Error ? err.message : String(err)}`);
199
+ }
200
+ };
201
+ }
202
+ // Classify session deaths for quota-aware stall detection
203
+ telegram.onClassifySessionDeath = async (sessionName) => {
204
+ try {
205
+ const output = sessionManager.captureOutput(sessionName, 100);
206
+ if (!output)
207
+ return null;
208
+ const quotaState = quotaTracker?.getState() ?? null;
209
+ const classification = classifySessionDeath(output, quotaState);
210
+ return { cause: classification.cause, detail: classification.detail };
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ };
216
+ // /login — seamless OAuth login flow
217
+ telegram.onLoginRequest = async (email, replyTopicId) => {
218
+ const tmuxPath = detectTmuxPath();
219
+ if (!tmuxPath) {
220
+ await telegram.sendToTopic(replyTopicId, 'tmux not found — cannot run login flow.');
221
+ return;
222
+ }
223
+ const loginSession = 'instar-login-flow';
224
+ try {
225
+ // Kill any existing login session
226
+ try {
227
+ execFileSync(tmuxPath, ['kill-session', '-t', `=${loginSession}`], { stdio: 'ignore' });
228
+ }
229
+ catch { /* not running */ }
230
+ // Start login command in tmux
231
+ const cliPath = claudePath || 'claude';
232
+ const loginCmd = email
233
+ ? `${cliPath} auth login --email "${email}"`
234
+ : `${cliPath} auth login`;
235
+ execFileSync(tmuxPath, ['new-session', '-d', '-s', loginSession, loginCmd], {
236
+ timeout: 10000,
237
+ });
238
+ await telegram.sendToTopic(replyTopicId, `Login flow started${email ? ` for ${email}` : ''}. Watching for OAuth URL...`);
239
+ // Poll for OAuth URL (up to 15 seconds)
240
+ let oauthUrl = null;
241
+ for (let i = 0; i < 30; i++) {
242
+ await new Promise(r => setTimeout(r, 500));
243
+ try {
244
+ const output = sessionManager.captureOutput(loginSession, 50) || '';
245
+ const urlMatch = output.match(/https:\/\/[^\s]+auth[^\s]*/i)
246
+ || output.match(/https:\/\/[^\s]+login[^\s]*/i)
247
+ || output.match(/https:\/\/[^\s]+oauth[^\s]*/i)
248
+ || output.match(/https:\/\/console\.anthropic\.com[^\s]*/i);
249
+ if (urlMatch) {
250
+ oauthUrl = urlMatch[0];
251
+ break;
252
+ }
253
+ }
254
+ catch { /* retry */ }
255
+ }
256
+ if (!oauthUrl) {
257
+ await telegram.sendToTopic(replyTopicId, 'Could not detect OAuth URL. Check the login session manually.');
258
+ return;
259
+ }
260
+ await telegram.sendToTopic(replyTopicId, `Open this URL to authenticate:\n\n${oauthUrl}\n\nI'll detect when you're done.`);
261
+ // Poll for auth completion (up to 5 minutes)
262
+ let authComplete = false;
263
+ for (let i = 0; i < 300; i++) {
264
+ await new Promise(r => setTimeout(r, 1000));
265
+ try {
266
+ const output = sessionManager.captureOutput(loginSession, 30) || '';
267
+ const lower = output.toLowerCase();
268
+ if (lower.includes('successfully') || lower.includes('authenticated') || lower.includes('logged in')) {
269
+ authComplete = true;
270
+ break;
271
+ }
272
+ // Detect "press Enter to continue" prompt
273
+ if (lower.includes('press enter') || lower.includes('press any key')) {
274
+ execFileSync(tmuxPath, ['send-keys', '-t', `=${loginSession}:`, 'Enter'], { timeout: 5000 });
275
+ await new Promise(r => setTimeout(r, 2000));
276
+ // Check if that completed it
277
+ const finalOutput = sessionManager.captureOutput(loginSession, 30) || '';
278
+ if (finalOutput.toLowerCase().includes('successfully') || finalOutput.toLowerCase().includes('authenticated')) {
279
+ authComplete = true;
280
+ }
281
+ break;
282
+ }
283
+ }
284
+ catch { /* retry */ }
285
+ }
286
+ // Clean up
287
+ try {
288
+ execFileSync(tmuxPath, ['kill-session', '-t', `=${loginSession}`], { stdio: 'ignore' });
289
+ }
290
+ catch { /* already ended */ }
291
+ if (authComplete) {
292
+ await telegram.sendToTopic(replyTopicId, 'Authentication successful! New sessions will use this account.');
293
+ }
294
+ else {
295
+ await telegram.sendToTopic(replyTopicId, 'Login flow ended. Check `claude auth status` to verify.');
296
+ }
297
+ }
298
+ catch (err) {
299
+ // Clean up on error
300
+ try {
301
+ execFileSync(tmuxPath, ['kill-session', '-t', `=${loginSession}`], { stdio: 'ignore' });
302
+ }
303
+ catch { /* ignore */ }
304
+ await telegram.sendToTopic(replyTopicId, `Login failed: ${err instanceof Error ? err.message : String(err)}`);
305
+ }
306
+ };
151
307
  }
152
308
  /**
153
309
  * Wire up Telegram message routing: topic messages → Claude sessions.
154
310
  * This is the core handler that makes Telegram topics work like sessions.
155
311
  */
156
- function wireTelegramRouting(telegram, sessionManager) {
312
+ function wireTelegramRouting(telegram, sessionManager, quotaTracker) {
157
313
  telegram.onTopicMessage = (msg) => {
158
314
  const topicId = msg.metadata?.messageThreadId ?? null;
159
315
  if (!topicId)
@@ -194,11 +350,27 @@ function wireTelegramRouting(telegram, sessionManager) {
194
350
  telegram.trackMessageInjection(topicId, targetSession, text);
195
351
  }
196
352
  else {
197
- // Session died — respawn with thread history
198
- telegram.sendToTopic(topicId, `🔄 Session restarting — message queued.`).catch(() => { });
199
- respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, text).catch(err => {
200
- console.error(`[telegram→session] Respawn failed:`, err);
201
- });
353
+ // Session died — check if it's a quota death before respawning
354
+ let isQuotaDeath = false;
355
+ try {
356
+ const output = sessionManager.captureOutput(targetSession, 100);
357
+ if (output) {
358
+ const quotaState = quotaTracker?.getState() ?? null;
359
+ const classification = classifySessionDeath(output, quotaState);
360
+ if (classification.cause === 'quota_exhaustion' && classification.confidence !== 'low') {
361
+ isQuotaDeath = true;
362
+ telegram.sendToTopic(topicId, `🔴 Session died — quota limit reached.\n${classification.detail}\n\n` +
363
+ `Use /switch-account to switch, /login to add an account, or reply again to force restart.`).catch(() => { });
364
+ }
365
+ }
366
+ }
367
+ catch { /* classification failed — fall through to respawn */ }
368
+ if (!isQuotaDeath) {
369
+ telegram.sendToTopic(topicId, `🔄 Session restarting — message queued.`).catch(() => { });
370
+ respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, text).catch(err => {
371
+ console.error(`[telegram→session] Respawn failed:`, err);
372
+ });
373
+ }
202
374
  }
203
375
  }
204
376
  else {
@@ -417,9 +589,27 @@ export async function startServer(options) {
417
589
  telegram = new TelegramAdapter(telegramConfig.config, config.stateDir);
418
590
  await telegram.start();
419
591
  console.log(pc.green(' Telegram connected'));
592
+ // Set up account switcher (Keychain-based OAuth account swapping)
593
+ const accountSwitcher = new AccountSwitcher();
594
+ // Set up quota notifier (Telegram alerts on threshold crossings)
595
+ const quotaNotifier = new QuotaNotifier(config.stateDir);
596
+ const alertTopicId = state.get('agent-attention-topic') ?? null;
597
+ quotaNotifier.configure(async (topicId, text) => { await telegram.sendToTopic(topicId, text); }, alertTopicId);
598
+ // Periodic quota notification check (every 10 minutes)
599
+ if (quotaTracker) {
600
+ setInterval(() => {
601
+ const quotaState = quotaTracker.getState();
602
+ if (quotaState) {
603
+ quotaNotifier.checkAndNotify(quotaState).catch(err => {
604
+ console.error('[QuotaNotifier] Check failed:', err);
605
+ });
606
+ }
607
+ }, 10 * 60 * 1000);
608
+ console.log(pc.green(' Quota notifications enabled'));
609
+ }
420
610
  // Wire up topic → session routing and session management callbacks
421
- wireTelegramRouting(telegram, sessionManager);
422
- wireTelegramCallbacks(telegram, sessionManager, state);
611
+ wireTelegramRouting(telegram, sessionManager, quotaTracker);
612
+ wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, accountSwitcher, config.sessions.claudePath);
423
613
  console.log(pc.green(' Telegram message routing active'));
424
614
  if (scheduler) {
425
615
  scheduler.setMessenger(telegram);
@@ -420,26 +420,21 @@ async function runClassicSetup() {
420
420
  console.log(` Auth token: ${pc.dim(authToken.slice(0, 8) + '...' + authToken.slice(-4))}`);
421
421
  console.log(` ${pc.dim('(full token saved in .instar/config.json — use for API calls)')}`);
422
422
  console.log();
423
- // Check if instar is globally installed (needed for server commands)
423
+ // Global install is required for auto-updates and persistent server commands.
424
+ // npx caches a snapshot that npm install -g doesn't touch, so agents
425
+ // installed only via npx can never auto-update.
424
426
  const isGloballyInstalled = isInstarGlobal();
425
427
  if (!isGloballyInstalled) {
426
- console.log(pc.dim(' Tip: instar is not installed globally. For persistent server'));
427
- console.log(pc.dim(' commands (start, stop, status), install it globally:'));
428
+ console.log(pc.dim(' Installing instar globally (required for auto-updates)...'));
428
429
  console.log();
429
- const installGlobal = await confirm({
430
- message: 'Install instar globally? (npm install -g instar)',
431
- default: true,
432
- });
433
- if (installGlobal) {
434
- try {
435
- console.log(pc.dim(' Running: npm install -g instar'));
436
- execFileSync('npm', ['install', '-g', 'instar'], { encoding: 'utf-8', stdio: 'inherit' });
437
- console.log(` ${pc.green('✓')} instar installed globally`);
438
- }
439
- catch {
440
- console.log(pc.yellow(' Could not install globally. You can run it later:'));
441
- console.log(` ${pc.cyan('npm install -g instar')}`);
442
- }
430
+ try {
431
+ execFileSync('npm', ['install', '-g', 'instar'], { encoding: 'utf-8', stdio: 'inherit' });
432
+ console.log(` ${pc.green('✓')} instar installed globally`);
433
+ }
434
+ catch {
435
+ console.log(pc.yellow(' Could not install globally. Auto-updates will not work.'));
436
+ console.log(pc.yellow(' Please run manually:'));
437
+ console.log(` ${pc.cyan('npm install -g instar')}`);
443
438
  }
444
439
  console.log();
445
440
  }
@@ -17,7 +17,7 @@
17
17
  * server process and exit. The new process binds to the port after
18
18
  * the old one releases it during shutdown.
19
19
  */
20
- import { spawn } from 'node:child_process';
20
+ import { spawn, execFileSync } from 'node:child_process';
21
21
  import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  export class AutoUpdater {
@@ -54,6 +54,12 @@ export class AutoUpdater {
54
54
  if (this.interval)
55
55
  return;
56
56
  const intervalMs = this.config.checkIntervalMinutes * 60 * 1000;
57
+ // Warn if running from npx cache (auto-updates won't work properly)
58
+ const scriptPath = process.argv[1] || '';
59
+ if (scriptPath.includes('.npm/_npx') || scriptPath.includes('/_npx/')) {
60
+ console.warn('[AutoUpdater] WARNING: Running from npx cache. Auto-updates require a global install.\n' +
61
+ '[AutoUpdater] Run: npm install -g instar');
62
+ }
57
63
  console.log(`[AutoUpdater] Started (every ${this.config.checkIntervalMinutes}m, ` +
58
64
  `autoApply: ${this.config.autoApply}, autoRestart: ${this.config.autoRestart})`);
59
65
  // Run first check after a short delay (don't block startup)
@@ -179,13 +185,36 @@ export class AutoUpdater {
179
185
  */
180
186
  selfRestart() {
181
187
  console.log('[AutoUpdater] Initiating self-restart...');
182
- // Build the command to restart
183
- // process.argv[0] = node binary
184
- // process.argv[1..] = CLI args (e.g., /path/to/cli.js server start --foreground)
185
- const args = process.argv.slice(1)
186
- .map(a => `'${a.replace(/'/g, "'\\''")}'`)
187
- .join(' ');
188
- const cmd = `sleep 2 && exec ${process.execPath} ${args}`;
188
+ // After an update, prefer the global binary (which has the new version)
189
+ // over process.argv (which may point to a stale npx cache).
190
+ // Extract non-path args (server, start, --foreground, --dir, etc.)
191
+ const cliArgs = process.argv.slice(2); // skip node + script path
192
+ let instarBin = null;
193
+ try {
194
+ const which = execFileSync('which', ['instar'], {
195
+ encoding: 'utf-8',
196
+ stdio: ['pipe', 'pipe', 'pipe'],
197
+ }).trim();
198
+ if (which && !which.includes('.npm/_npx')) {
199
+ instarBin = which;
200
+ }
201
+ }
202
+ catch { /* not found globally */ }
203
+ let cmd;
204
+ if (instarBin) {
205
+ // Use the global binary — guaranteed to be the updated version
206
+ const quotedArgs = cliArgs.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
207
+ cmd = `sleep 2 && exec '${instarBin.replace(/'/g, "'\\''")}' ${quotedArgs}`;
208
+ console.log(`[AutoUpdater] Will restart from global binary: ${instarBin}`);
209
+ }
210
+ else {
211
+ // Fallback: use the original process.argv (global not available)
212
+ const args = process.argv.slice(1)
213
+ .map(a => `'${a.replace(/'/g, "'\\''")}'`)
214
+ .join(' ');
215
+ cmd = `sleep 2 && exec ${process.execPath} ${args}`;
216
+ console.log('[AutoUpdater] No global binary found, restarting from current path');
217
+ }
189
218
  try {
190
219
  const child = spawn('sh', ['-c', cmd], {
191
220
  detached: true,
@@ -98,8 +98,9 @@ export class UpdateChecker {
98
98
  };
99
99
  }
100
100
  try {
101
- // Execute npm update
102
- await this.execAsync('npm', ['update', '-g', 'instar'], 120000);
101
+ // Use `npm install -g instar@latest` — `npm update -g` is unreliable
102
+ // for global packages and often silently fails to change the version
103
+ await this.execAsync('npm', ['install', '-g', 'instar@latest'], 120000);
103
104
  }
104
105
  catch (err) {
105
106
  return {
@@ -67,6 +67,10 @@ export interface JobDefinition {
67
67
  tags?: string[];
68
68
  /** Telegram topic ID this job reports to (auto-created if not set) */
69
69
  topicId?: number;
70
+ /** Set to false to disable all Telegram notifications for this job.
71
+ * Also prevents topic creation in ensureJobTopics.
72
+ * Useful for low-signal jobs that report via other channels. */
73
+ telegramNotify?: boolean;
70
74
  /** Grounding configuration — what context this job needs at session start */
71
75
  grounding?: JobGrounding;
72
76
  /** LLM supervision tier — see docs/LLM-SUPERVISED-EXECUTION.md */
package/dist/index.d.ts CHANGED
@@ -28,6 +28,8 @@ export { HealthChecker } from './monitoring/HealthChecker.js';
28
28
  export { QuotaTracker } from './monitoring/QuotaTracker.js';
29
29
  export type { RemoteQuotaResult } from './monitoring/QuotaTracker.js';
30
30
  export { classifySessionDeath } from './monitoring/QuotaExhaustionDetector.js';
31
+ export { AccountSwitcher } from './monitoring/AccountSwitcher.js';
32
+ export { QuotaNotifier } from './monitoring/QuotaNotifier.js';
31
33
  export { SleepWakeDetector } from './core/SleepWakeDetector.js';
32
34
  export { TelegramAdapter } from './messaging/TelegramAdapter.js';
33
35
  export type { TelegramConfig } from './messaging/TelegramAdapter.js';
package/dist/index.js CHANGED
@@ -29,6 +29,8 @@ export { corsMiddleware, authMiddleware, rateLimiter, requestTimeout, errorHandl
29
29
  export { HealthChecker } from './monitoring/HealthChecker.js';
30
30
  export { QuotaTracker } from './monitoring/QuotaTracker.js';
31
31
  export { classifySessionDeath } from './monitoring/QuotaExhaustionDetector.js';
32
+ export { AccountSwitcher } from './monitoring/AccountSwitcher.js';
33
+ export { QuotaNotifier } from './monitoring/QuotaNotifier.js';
32
34
  export { SleepWakeDetector } from './core/SleepWakeDetector.js';
33
35
  // Messaging
34
36
  export { TelegramAdapter } from './messaging/TelegramAdapter.js';
@@ -86,6 +86,13 @@ export declare class TelegramAdapter implements MessagingAdapter {
86
86
  onIsSessionAlive: ((tmuxSession: string) => boolean) | null;
87
87
  onIsSessionActive: ((tmuxSession: string) => Promise<boolean>) | null;
88
88
  onAttentionStatusChange: ((itemId: string, status: string) => Promise<void>) | null;
89
+ onSwitchAccountRequest: ((target: string, replyTopicId: number) => Promise<void>) | null;
90
+ onQuotaStatusRequest: ((replyTopicId: number) => Promise<void>) | null;
91
+ onLoginRequest: ((email: string | null, replyTopicId: number) => Promise<void>) | null;
92
+ onClassifySessionDeath: ((sessionName: string) => Promise<{
93
+ cause: string;
94
+ detail: string;
95
+ } | null>) | null;
89
96
  constructor(config: TelegramConfig, stateDir: string);
90
97
  start(): Promise<void>;
91
98
  stop(): Promise<void>;
@@ -70,6 +70,11 @@ export class TelegramAdapter {
70
70
  onIsSessionActive = null;
71
71
  // Attention queue callbacks
72
72
  onAttentionStatusChange = null;
73
+ // Quota management callbacks
74
+ onSwitchAccountRequest = null;
75
+ onQuotaStatusRequest = null;
76
+ onLoginRequest = null;
77
+ onClassifySessionDeath = null;
73
78
  constructor(config, stateDir) {
74
79
  this.config = config;
75
80
  this.stateDir = stateDir;
@@ -393,11 +398,31 @@ export class TelegramAdapter {
393
398
  }
394
399
  }
395
400
  pending.alerted = true;
396
- const status = alive ? 'running but not responding' : 'no longer running';
397
- const minutesAgo = Math.round((now - pending.injectedAt) / 60000);
398
- this.sendToTopic(pending.topicId, `\u26a0\ufe0f No response after ${minutesAgo} minutes. Session "${pending.sessionName}" is ${status}.\n\nMessage: "${pending.messageText}..."${alive ? '\n\nTry /interrupt to unstick, or /restart to respawn.' : '\n\nSend another message to auto-respawn.'}`).catch(err => {
399
- console.error(`[telegram] Stall alert failed: ${err}`);
400
- });
401
+ // Classify the stall check if it's a quota death
402
+ let isQuotaDeath = false;
403
+ if (this.onClassifySessionDeath) {
404
+ try {
405
+ const classification = await this.onClassifySessionDeath(pending.sessionName);
406
+ if (classification && classification.cause === 'quota_exhaustion') {
407
+ isQuotaDeath = true;
408
+ this.sendToTopic(pending.topicId, `\ud83d\udd34 Session hit quota limit \u2014 "${pending.sessionName}" can't respond.\n\n` +
409
+ `${classification.detail}\n\n` +
410
+ `Use /quota to check accounts, /switch-account to switch, or /login to authenticate a new account.`).catch(err => {
411
+ console.error(`[telegram] Quota stall alert failed: ${err}`);
412
+ });
413
+ }
414
+ }
415
+ catch {
416
+ // Classification failed — fall through to generic
417
+ }
418
+ }
419
+ if (!isQuotaDeath) {
420
+ const status = alive ? 'running but not responding' : 'no longer running';
421
+ const minutesAgo = Math.round((now - pending.injectedAt) / 60000);
422
+ this.sendToTopic(pending.topicId, `\u26a0\ufe0f No response after ${minutesAgo} minutes. Session "${pending.sessionName}" is ${status}.\n\nMessage: "${pending.messageText}..."${alive ? '\n\nTry /interrupt to unstick, or /restart to respawn.' : '\n\nSend another message to auto-respawn.'}`).catch(err => {
423
+ console.error(`[telegram] Stall alert failed: ${err}`);
424
+ });
425
+ }
401
426
  }
402
427
  // Clean up old entries (older than 30 minutes, already alerted)
403
428
  for (const [key, pending] of this.pendingMessages) {
@@ -683,6 +708,49 @@ export class TelegramAdapter {
683
708
  await this.sendToTopic(topicId, lines.join('\n')).catch(() => { });
684
709
  return true;
685
710
  }
711
+ // /switch-account (or /sa) <target> — switch active Claude account
712
+ const switchMatch = text.match(/^\/(?:switch[-_]?account|sa)\s+(.+)$/i);
713
+ if (switchMatch) {
714
+ const target = switchMatch[1].trim();
715
+ if (this.onSwitchAccountRequest) {
716
+ this.onSwitchAccountRequest(target, topicId).catch(err => {
717
+ console.error('[telegram] Switch account failed:', err);
718
+ this.sendToTopic(topicId, `Switch failed: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
719
+ });
720
+ }
721
+ else {
722
+ await this.sendToTopic(topicId, 'Account switching not available.').catch(() => { });
723
+ }
724
+ return true;
725
+ }
726
+ // /quota (or /q) — show multi-account quota summary
727
+ if (cmd === '/quota' || cmd === '/q') {
728
+ if (this.onQuotaStatusRequest) {
729
+ this.onQuotaStatusRequest(topicId).catch(err => {
730
+ console.error('[telegram] Quota status failed:', err);
731
+ this.sendToTopic(topicId, `Quota check failed: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
732
+ });
733
+ }
734
+ else {
735
+ await this.sendToTopic(topicId, 'Quota status not available.').catch(() => { });
736
+ }
737
+ return true;
738
+ }
739
+ // /login [email] — seamless OAuth login from Telegram
740
+ const loginMatch = text.match(/^\/login(?:\s+(.+))?$/i);
741
+ if (loginMatch) {
742
+ const email = loginMatch[1]?.trim() || null;
743
+ if (this.onLoginRequest) {
744
+ this.onLoginRequest(email, topicId).catch(err => {
745
+ console.error('[telegram] Login flow failed:', err);
746
+ this.sendToTopic(topicId, `Login failed: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
747
+ });
748
+ }
749
+ else {
750
+ await this.sendToTopic(topicId, 'Login not available.').catch(() => { });
751
+ }
752
+ return true;
753
+ }
686
754
  return false;
687
755
  }
688
756
  // ── Message Log ────────────────────────────────────────────
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Account Switcher — swap active Claude Code account via Keychain manipulation.
3
+ *
4
+ * Reads/writes the macOS Keychain entry used by Claude Code for OAuth credentials.
5
+ * Supports fuzzy matching of account names (e.g., "dawn" matches "dawn@sagemindai.io").
6
+ *
7
+ * Ported from Dawn's dawn-server equivalent for general Instar use.
8
+ */
9
+ export interface SwitchResult {
10
+ success: boolean;
11
+ message: string;
12
+ previousAccount: string | null;
13
+ newAccount: string | null;
14
+ }
15
+ export declare class AccountSwitcher {
16
+ private registryPath;
17
+ private keychainAccount;
18
+ constructor(registryPath?: string);
19
+ /**
20
+ * Switch to a target account. Supports fuzzy matching:
21
+ * - "dawn" matches "dawn@sagemindai.io"
22
+ * - Full email also works
23
+ */
24
+ switchAccount(target: string): Promise<SwitchResult>;
25
+ /**
26
+ * Get status of all accounts.
27
+ */
28
+ getAccountStatuses(): Array<{
29
+ email: string;
30
+ name: string | null;
31
+ isActive: boolean;
32
+ hasToken: boolean;
33
+ tokenExpired: boolean;
34
+ isStale: boolean;
35
+ weeklyPercent: number;
36
+ fiveHourPercent: number | null;
37
+ }>;
38
+ private resolveAccount;
39
+ private readFromKeychain;
40
+ private writeToKeychain;
41
+ private loadRegistry;
42
+ private saveRegistry;
43
+ }
44
+ //# sourceMappingURL=AccountSwitcher.d.ts.map
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Account Switcher — swap active Claude Code account via Keychain manipulation.
3
+ *
4
+ * Reads/writes the macOS Keychain entry used by Claude Code for OAuth credentials.
5
+ * Supports fuzzy matching of account names (e.g., "dawn" matches "dawn@sagemindai.io").
6
+ *
7
+ * Ported from Dawn's dawn-server equivalent for general Instar use.
8
+ */
9
+ import { execSync } from 'node:child_process';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
13
+ export class AccountSwitcher {
14
+ registryPath;
15
+ keychainAccount;
16
+ constructor(registryPath) {
17
+ this.registryPath = registryPath || path.join(process.env.HOME || '', '.dawn-server/account-registry.json');
18
+ // Get the macOS username for Keychain access
19
+ try {
20
+ this.keychainAccount = execSync('whoami', { encoding: 'utf-8' }).trim();
21
+ }
22
+ catch {
23
+ this.keychainAccount = 'justin';
24
+ }
25
+ }
26
+ /**
27
+ * Switch to a target account. Supports fuzzy matching:
28
+ * - "dawn" matches "dawn@sagemindai.io"
29
+ * - Full email also works
30
+ */
31
+ async switchAccount(target) {
32
+ const registry = this.loadRegistry();
33
+ if (!registry) {
34
+ return { success: false, message: 'Account registry not found', previousAccount: null, newAccount: null };
35
+ }
36
+ const resolvedEmail = this.resolveAccount(target, registry);
37
+ if (!resolvedEmail) {
38
+ const available = Object.keys(registry.accounts)
39
+ .map(e => {
40
+ const a = registry.accounts[e];
41
+ return `${a.name || 'unknown'} (${e})`;
42
+ })
43
+ .join(', ');
44
+ return {
45
+ success: false,
46
+ message: `Unknown account "${target}". Available: ${available}`,
47
+ previousAccount: registry.activeAccountEmail,
48
+ newAccount: null,
49
+ };
50
+ }
51
+ const account = registry.accounts[resolvedEmail];
52
+ if (!account) {
53
+ return {
54
+ success: false,
55
+ message: `Account ${resolvedEmail} not in registry`,
56
+ previousAccount: registry.activeAccountEmail,
57
+ newAccount: null,
58
+ };
59
+ }
60
+ if (!account.cachedOAuth?.accessToken) {
61
+ return {
62
+ success: false,
63
+ message: `No cached token for ${resolvedEmail}. Use /login to authenticate.`,
64
+ previousAccount: registry.activeAccountEmail,
65
+ newAccount: null,
66
+ };
67
+ }
68
+ if (account.cachedOAuth.expiresAt && account.cachedOAuth.expiresAt < Date.now()) {
69
+ return {
70
+ success: false,
71
+ message: `Token for ${resolvedEmail} expired. Use /login to re-authenticate.`,
72
+ previousAccount: registry.activeAccountEmail,
73
+ newAccount: null,
74
+ };
75
+ }
76
+ if (registry.activeAccountEmail === resolvedEmail) {
77
+ return {
78
+ success: true,
79
+ message: `${resolvedEmail} is already the active account.`,
80
+ previousAccount: resolvedEmail,
81
+ newAccount: resolvedEmail,
82
+ };
83
+ }
84
+ const previousAccount = registry.activeAccountEmail;
85
+ try {
86
+ const currentKeychainData = this.readFromKeychain();
87
+ const newKeychainData = {
88
+ ...currentKeychainData,
89
+ claudeAiOauth: {
90
+ ...currentKeychainData.claudeAiOauth,
91
+ accessToken: account.cachedOAuth.accessToken,
92
+ },
93
+ };
94
+ this.writeToKeychain(newKeychainData);
95
+ }
96
+ catch (err) {
97
+ return {
98
+ success: false,
99
+ message: `Failed to write Keychain: ${err instanceof Error ? err.message : String(err)}`,
100
+ previousAccount,
101
+ newAccount: null,
102
+ };
103
+ }
104
+ try {
105
+ registry.activeAccountEmail = resolvedEmail;
106
+ registry.lastUpdated = new Date().toISOString();
107
+ this.saveRegistry(registry);
108
+ }
109
+ catch (err) {
110
+ console.error('[AccountSwitcher] Failed to update registry:', err);
111
+ }
112
+ const name = account.name || resolvedEmail;
113
+ return {
114
+ success: true,
115
+ message: `Switched to ${name} (${resolvedEmail}). New sessions will use this account.`,
116
+ previousAccount,
117
+ newAccount: resolvedEmail,
118
+ };
119
+ }
120
+ /**
121
+ * Get status of all accounts.
122
+ */
123
+ getAccountStatuses() {
124
+ const registry = this.loadRegistry();
125
+ if (!registry)
126
+ return [];
127
+ return Object.values(registry.accounts).map(account => {
128
+ const hasToken = !!account.cachedOAuth?.accessToken;
129
+ const tokenExpired = hasToken && account.cachedOAuth.expiresAt < Date.now();
130
+ return {
131
+ email: account.email,
132
+ name: account.name,
133
+ isActive: account.email === registry.activeAccountEmail,
134
+ hasToken,
135
+ tokenExpired,
136
+ isStale: !!account.staleSince,
137
+ weeklyPercent: account.lastQuotaSnapshot?.percentUsed ?? 0,
138
+ fiveHourPercent: account.lastQuotaSnapshot?.fiveHourUtilization ?? null,
139
+ };
140
+ });
141
+ }
142
+ resolveAccount(target, registry) {
143
+ const lower = target.toLowerCase().trim();
144
+ if (registry.accounts[lower])
145
+ return lower;
146
+ for (const email of Object.keys(registry.accounts)) {
147
+ if (email.toLowerCase() === lower)
148
+ return email;
149
+ }
150
+ for (const email of Object.keys(registry.accounts)) {
151
+ const prefix = email.split('@')[0].toLowerCase();
152
+ if (prefix === lower)
153
+ return email;
154
+ }
155
+ for (const [email, account] of Object.entries(registry.accounts)) {
156
+ if (account.name && account.name.toLowerCase().includes(lower)) {
157
+ return email;
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ readFromKeychain() {
163
+ const result = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w 2>/dev/null`, { encoding: 'utf-8', timeout: 10000 });
164
+ return JSON.parse(result.trim());
165
+ }
166
+ writeToKeychain(data) {
167
+ const jsonStr = JSON.stringify(data);
168
+ const hexStr = Buffer.from(jsonStr).toString('hex');
169
+ execSync(`security -i <<< 'add-generic-password -U -a "${this.keychainAccount}" -s "${KEYCHAIN_SERVICE}" -X "${hexStr}"'`, { timeout: 10000, shell: '/bin/bash' });
170
+ }
171
+ loadRegistry() {
172
+ try {
173
+ if (!fs.existsSync(this.registryPath))
174
+ return null;
175
+ return JSON.parse(fs.readFileSync(this.registryPath, 'utf-8'));
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ saveRegistry(registry) {
182
+ fs.writeFileSync(this.registryPath, JSON.stringify(registry, null, 2), { mode: 0o600 });
183
+ }
184
+ }
185
+ //# sourceMappingURL=AccountSwitcher.js.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Quota Notifier — sends alerts when quota thresholds are crossed.
3
+ *
4
+ * Handles both weekly and 5-hour rate limit notifications independently.
5
+ * Deduplicates notifications so the same threshold doesn't spam.
6
+ * Persists state to survive server restarts.
7
+ *
8
+ * Ported from Dawn's dawn-server equivalent for general Instar use.
9
+ */
10
+ import type { QuotaState } from '../core/types.js';
11
+ type SendFn = (topicId: number, text: string) => Promise<void>;
12
+ export declare class QuotaNotifier {
13
+ private state;
14
+ private statePath;
15
+ private sendToTopic;
16
+ private alertTopicId;
17
+ constructor(stateDir: string);
18
+ /**
19
+ * Configure the notification target.
20
+ */
21
+ configure(sendFn: SendFn, alertTopicId: number | null): void;
22
+ /**
23
+ * Check quota state and send notifications if thresholds are crossed.
24
+ */
25
+ checkAndNotify(quotaState: QuotaState): Promise<void>;
26
+ /**
27
+ * Send an ad-hoc alert (e.g., from session death detection).
28
+ */
29
+ sendAlert(message: string): Promise<void>;
30
+ private checkWeeklyThreshold;
31
+ private checkFiveHourThreshold;
32
+ private send;
33
+ private recordNotification;
34
+ private loadState;
35
+ private saveState;
36
+ }
37
+ export {};
38
+ //# sourceMappingURL=QuotaNotifier.d.ts.map
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Quota Notifier — sends alerts when quota thresholds are crossed.
3
+ *
4
+ * Handles both weekly and 5-hour rate limit notifications independently.
5
+ * Deduplicates notifications so the same threshold doesn't spam.
6
+ * Persists state to survive server restarts.
7
+ *
8
+ * Ported from Dawn's dawn-server equivalent for general Instar use.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ const WEEKLY_THRESHOLDS = {
13
+ warning: 70,
14
+ critical: 85,
15
+ limit: 95,
16
+ };
17
+ const FIVE_HOUR_THRESHOLDS = {
18
+ warning: 80,
19
+ limit: 95,
20
+ };
21
+ export class QuotaNotifier {
22
+ state;
23
+ statePath;
24
+ sendToTopic = null;
25
+ alertTopicId = null;
26
+ constructor(stateDir) {
27
+ this.statePath = path.join(stateDir, 'quota-notifications.json');
28
+ this.state = this.loadState();
29
+ }
30
+ /**
31
+ * Configure the notification target.
32
+ */
33
+ configure(sendFn, alertTopicId) {
34
+ this.sendToTopic = sendFn;
35
+ this.alertTopicId = alertTopicId;
36
+ }
37
+ /**
38
+ * Check quota state and send notifications if thresholds are crossed.
39
+ */
40
+ async checkAndNotify(quotaState) {
41
+ const weeklyPercent = quotaState.usagePercent ?? 0;
42
+ await this.checkWeeklyThreshold(weeklyPercent);
43
+ const fiveHourPercent = quotaState.fiveHourPercent ?? null;
44
+ if (fiveHourPercent !== null) {
45
+ await this.checkFiveHourThreshold(fiveHourPercent);
46
+ }
47
+ }
48
+ /**
49
+ * Send an ad-hoc alert (e.g., from session death detection).
50
+ */
51
+ async sendAlert(message) {
52
+ await this.send(message);
53
+ }
54
+ async checkWeeklyThreshold(percent) {
55
+ let currentLevel = null;
56
+ if (percent >= WEEKLY_THRESHOLDS.limit)
57
+ currentLevel = 'limit';
58
+ else if (percent >= WEEKLY_THRESHOLDS.critical)
59
+ currentLevel = 'critical';
60
+ else if (percent >= WEEKLY_THRESHOLDS.warning)
61
+ currentLevel = 'warning';
62
+ if (currentLevel && currentLevel !== this.state.lastWeeklyLevel) {
63
+ const labels = { warning: 'WARNING', critical: 'CRITICAL', limit: 'LIMIT REACHED' };
64
+ await this.send(`[QUOTA ${labels[currentLevel]}] Weekly at ${percent}%`);
65
+ this.state.lastWeeklyLevel = currentLevel;
66
+ this.recordNotification('weekly', currentLevel, percent);
67
+ this.saveState();
68
+ }
69
+ if (percent < WEEKLY_THRESHOLDS.warning && this.state.lastWeeklyLevel) {
70
+ this.state.lastWeeklyLevel = null;
71
+ this.saveState();
72
+ }
73
+ }
74
+ async checkFiveHourThreshold(percent) {
75
+ let currentLevel = null;
76
+ if (percent >= FIVE_HOUR_THRESHOLDS.limit)
77
+ currentLevel = 'limit';
78
+ else if (percent >= FIVE_HOUR_THRESHOLDS.warning)
79
+ currentLevel = 'warning';
80
+ if (currentLevel && currentLevel !== this.state.lastFiveHourLevel) {
81
+ const labels = { warning: 'WARNING', limit: 'FULL' };
82
+ await this.send(`[5-HOUR RATE LIMIT ${labels[currentLevel]}] At ${percent}%. Sessions may fail.`);
83
+ this.state.lastFiveHourLevel = currentLevel;
84
+ this.recordNotification('five_hour', currentLevel, percent);
85
+ this.saveState();
86
+ }
87
+ if (percent < FIVE_HOUR_THRESHOLDS.warning && this.state.lastFiveHourLevel) {
88
+ this.state.lastFiveHourLevel = null;
89
+ this.saveState();
90
+ }
91
+ }
92
+ async send(text) {
93
+ if (!this.sendToTopic || !this.alertTopicId) {
94
+ console.log(`[QuotaNotifier] ${text}`);
95
+ return;
96
+ }
97
+ try {
98
+ await this.sendToTopic(this.alertTopicId, text);
99
+ }
100
+ catch (err) {
101
+ console.error('[QuotaNotifier] Failed to send:', err);
102
+ }
103
+ }
104
+ recordNotification(type, level, percent) {
105
+ this.state.notifications.push({
106
+ type,
107
+ level,
108
+ percentUsed: percent,
109
+ timestamp: new Date().toISOString(),
110
+ });
111
+ if (this.state.notifications.length > 100) {
112
+ this.state.notifications = this.state.notifications.slice(-100);
113
+ }
114
+ this.state.lastNotifiedAt = new Date().toISOString();
115
+ }
116
+ loadState() {
117
+ try {
118
+ if (fs.existsSync(this.statePath)) {
119
+ return JSON.parse(fs.readFileSync(this.statePath, 'utf-8'));
120
+ }
121
+ }
122
+ catch { /* fresh state */ }
123
+ return { lastWeeklyLevel: null, lastFiveHourLevel: null, notifications: [], lastNotifiedAt: null };
124
+ }
125
+ saveState() {
126
+ try {
127
+ const dir = path.dirname(this.statePath);
128
+ if (!fs.existsSync(dir))
129
+ fs.mkdirSync(dir, { recursive: true });
130
+ fs.writeFileSync(this.statePath, JSON.stringify(this.state, null, 2));
131
+ }
132
+ catch (err) {
133
+ console.error('[QuotaNotifier] Failed to save state:', err);
134
+ }
135
+ }
136
+ }
137
+ //# sourceMappingURL=QuotaNotifier.js.map
@@ -362,9 +362,11 @@ export class JobScheduler {
362
362
  }
363
363
  // Try to drain the queue now that a slot is available
364
364
  this.processQueue();
365
- // Skip notifications if no messaging configured
365
+ // Skip notifications if no messaging configured or job opted out
366
366
  if (!this.messenger && !this.telegram)
367
367
  return;
368
+ if (job.telegramNotify === false)
369
+ return;
368
370
  // Capture the last output from the tmux session
369
371
  let output = '';
370
372
  try {
@@ -415,9 +417,10 @@ export class JobScheduler {
415
417
  else {
416
418
  summary += '\n_No output captured (session already closed)_';
417
419
  }
418
- // Skip Telegram notification for successful jobs with no meaningful output
419
- // Prevents empty notification spam (e.g., dispatch-check when dispatch is unconfigured)
420
- if (!failed && (!output || !output.trim())) {
420
+ // Skip Telegram notification for jobs with no meaningful output — applies regardless of status.
421
+ // Failure alerts are already handled by alertOnConsecutiveFailures above.
422
+ // Prevents "No output captured (session already closed)" spam on every failed cycle.
423
+ if (!output || !output.trim()) {
421
424
  console.log(`[scheduler] Skipping notification for ${job.slug} — no meaningful output`);
422
425
  return;
423
426
  }
@@ -463,6 +466,9 @@ export class JobScheduler {
463
466
  // Load existing topic mappings
464
467
  const mappings = this.state.get('job-topic-mappings') ?? {};
465
468
  for (const job of enabledJobs) {
469
+ // Opt-out: skip topic creation entirely when telegramNotify is explicitly false
470
+ if (job.telegramNotify === false)
471
+ continue;
466
472
  // If job already has a topicId (from jobs.json or previous mapping), use it
467
473
  if (job.topicId) {
468
474
  mappings[job.slug] = job.topicId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.7.32",
3
+ "version": "0.7.34",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",