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.
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/dist/cli.js +0 -0
- package/dist/commands/server.js +199 -9
- package/dist/commands/setup.js +12 -17
- package/dist/core/AutoUpdater.js +37 -8
- package/dist/core/UpdateChecker.js +3 -2
- package/dist/core/types.d.ts +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/messaging/TelegramAdapter.d.ts +7 -0
- package/dist/messaging/TelegramAdapter.js +73 -5
- package/dist/monitoring/AccountSwitcher.d.ts +44 -0
- package/dist/monitoring/AccountSwitcher.js +185 -0
- package/dist/monitoring/QuotaNotifier.d.ts +38 -0
- package/dist/monitoring/QuotaNotifier.js +137 -0
- package/dist/scheduler/JobScheduler.js +10 -4
- package/package.json +1 -1
|
@@ -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
|
package/dist/commands/server.js
CHANGED
|
@@ -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 —
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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);
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
-
//
|
|
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('
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
}
|
package/dist/core/AutoUpdater.js
CHANGED
|
@@ -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
|
-
//
|
|
183
|
-
// process.argv
|
|
184
|
-
//
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
//
|
|
102
|
-
|
|
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 {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
397
|
-
|
|
398
|
-
this.
|
|
399
|
-
|
|
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
|
|
419
|
-
//
|
|
420
|
-
|
|
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;
|