instar 0.7.31 → 0.7.33
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/README.md +102 -3
- 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/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/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/README.md
CHANGED
|
@@ -73,6 +73,11 @@ instar lifeline start # Start lifeline (supervises server, queues mess
|
|
|
73
73
|
instar lifeline stop # Stop lifeline and server
|
|
74
74
|
instar lifeline status # Check lifeline health
|
|
75
75
|
|
|
76
|
+
# Auto-start on login (macOS LaunchAgent / Linux systemd)
|
|
77
|
+
instar autostart install # Agent starts when you log in
|
|
78
|
+
instar autostart uninstall # Remove auto-start
|
|
79
|
+
instar autostart status # Check if auto-start is installed
|
|
80
|
+
|
|
76
81
|
# Add capabilities
|
|
77
82
|
instar add telegram --token BOT_TOKEN --chat-id CHAT_ID
|
|
78
83
|
instar add email --credentials-file ./credentials.json [--token-file ./token.json]
|
|
@@ -91,12 +96,18 @@ instar feedback --type bug --title "Session timeout" --description "Details..."
|
|
|
91
96
|
## Highlights
|
|
92
97
|
|
|
93
98
|
- **[Persistent Server](#persistent-server)** -- Express server in tmux. Runs 24/7, survives disconnects, auto-recovers.
|
|
99
|
+
- **[Lifeline](#lifeline)** -- Persistent Telegram supervisor that auto-recovers from crashes and queues messages during downtime.
|
|
100
|
+
- **[Auto-Start on Login](#auto-start-on-login)** -- macOS LaunchAgent / Linux systemd service. Agent starts when your computer boots.
|
|
101
|
+
- **[AutoUpdater](#autoupdater)** -- Built-in update engine. Checks npm, applies updates, notifies via Telegram, self-restarts. No Claude session needed.
|
|
102
|
+
- **[AutoDispatcher](#autodispatcher)** -- Receives intelligence dispatches from Dawn. Lessons, strategies, and configuration applied automatically.
|
|
94
103
|
- **[Job Scheduler](#job-scheduler)** -- Cron-based task execution with priority levels, model tiering, and quota awareness.
|
|
95
104
|
- **[Identity System](#identity-that-survives-context-death)** -- AGENT.md + USER.md + MEMORY.md with hooks that enforce continuity across compaction.
|
|
96
105
|
- **[Telegram Integration](#telegram-integration)** -- Two-way messaging. Each job gets its own topic. Your group becomes a living dashboard.
|
|
97
106
|
- **[Relationship Tracking](#relationships-as-fundamental-infrastructure)** -- Cross-platform identity resolution, significance scoring, context injection.
|
|
98
107
|
- **[Evolution System](#evolution-system)** -- Four subsystems for structured growth: proposal queue, learning registry, gap tracking, and commitment follow-through.
|
|
99
108
|
- **[Self-Evolution](#self-evolution)** -- The agent modifies its own jobs, hooks, skills, and infrastructure. It builds what it needs.
|
|
109
|
+
- **[Capability Discovery](#capability-discovery)** -- Agents know all their capabilities from the moment they start. Context-triggered feature suggestions.
|
|
110
|
+
- **[Innovation Detection](#innovation-detection)** -- Agents detect when user-built features could benefit all Instar agents and submit improvement feedback.
|
|
100
111
|
- **[Behavioral Hooks](#behavioral-hooks)** -- Structural guardrails: identity injection, dangerous command guards, grounding before messaging.
|
|
101
112
|
- **[Default Coherence Jobs](#default-coherence-jobs)** -- Health checks, reflection, relationship maintenance. A circadian rhythm out of the box.
|
|
102
113
|
- **[Feedback Loop](#the-feedback-loop-a-rising-tide-lifts-all-ships)** -- Your agent reports issues, we fix them, every agent gets the update. A rising tide lifts all ships.
|
|
@@ -282,6 +293,76 @@ Two-way messaging via Telegram forum topics. Each topic maps to a Claude session
|
|
|
282
293
|
- Sessions auto-respawn with conversation history when they expire
|
|
283
294
|
- Every scheduled job gets its own topic -- your group becomes a **living dashboard**
|
|
284
295
|
|
|
296
|
+
### Lifeline
|
|
297
|
+
|
|
298
|
+
The Lifeline is a persistent Telegram connection that supervises your agent's server. It runs outside the server process, so it can detect crashes and recover automatically.
|
|
299
|
+
|
|
300
|
+
- **Auto-recovery** -- If the server goes down, the Lifeline restarts it
|
|
301
|
+
- **Message queuing** -- Messages received during downtime are queued and delivered when the server comes back
|
|
302
|
+
- **First-boot greeting** -- Your agent greets you on Telegram in its own voice the first time it starts
|
|
303
|
+
- **Lifeline topic** -- Created during setup with a green icon, dedicated to agent health
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
instar lifeline start # Start lifeline (supervises server, queues messages)
|
|
307
|
+
instar lifeline stop # Stop lifeline and server
|
|
308
|
+
instar lifeline status # Check lifeline health
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Auto-Start on Login
|
|
312
|
+
|
|
313
|
+
Your agent can start automatically when you log into your computer. The setup wizard offers to install this during initial configuration.
|
|
314
|
+
|
|
315
|
+
- **macOS** -- Installs a LaunchAgent plist that starts the Lifeline on login
|
|
316
|
+
- **Linux** -- Installs a systemd user service
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
instar autostart install # Install auto-start
|
|
320
|
+
instar autostart uninstall # Remove auto-start
|
|
321
|
+
instar autostart status # Check if installed
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### AutoUpdater
|
|
325
|
+
|
|
326
|
+
A built-in update engine that runs inside the server process -- no Claude session needed.
|
|
327
|
+
|
|
328
|
+
- Checks npm for new versions every 30 minutes
|
|
329
|
+
- Auto-applies updates when available
|
|
330
|
+
- Notifies you via Telegram with a changelog summary
|
|
331
|
+
- Self-restarts after updating
|
|
332
|
+
- Supersedes the old `update-check` prompt job (which is now disabled by default)
|
|
333
|
+
|
|
334
|
+
Status: `GET /updates/auto`
|
|
335
|
+
|
|
336
|
+
### AutoDispatcher
|
|
337
|
+
|
|
338
|
+
Receives intelligence dispatches from Dawn -- the AI that maintains Instar. Dispatches flow automatically without requiring a Claude session.
|
|
339
|
+
|
|
340
|
+
- **Passive dispatches** (lessons, strategies) -- Applied automatically to agent memory and configuration
|
|
341
|
+
- **Action/configuration dispatches** -- Executed programmatically by the DispatchExecutor
|
|
342
|
+
- **Security dispatches** -- Deferred for manual review
|
|
343
|
+
- Polls every 30 minutes
|
|
344
|
+
- Supersedes the old `dispatch-check` prompt job (which is now disabled by default)
|
|
345
|
+
|
|
346
|
+
Status: `GET /dispatches/auto`
|
|
347
|
+
|
|
348
|
+
### Capability Discovery
|
|
349
|
+
|
|
350
|
+
Agents know all their capabilities from the moment they start.
|
|
351
|
+
|
|
352
|
+
- `GET /capabilities` endpoint returns a structured feature guide
|
|
353
|
+
- Session-start hook queries capabilities and outputs a feature summary
|
|
354
|
+
- Context-triggered feature suggestions -- the agent surfaces relevant capabilities when they'd help
|
|
355
|
+
|
|
356
|
+
### Innovation Detection
|
|
357
|
+
|
|
358
|
+
Agents proactively detect when user-built features could benefit all Instar agents. When the agent builds a custom script or capability, it evaluates whether the innovation passes three tests:
|
|
359
|
+
|
|
360
|
+
1. Does it solve a general problem (not just this user's specific case)?
|
|
361
|
+
2. Would it be useful as a default capability?
|
|
362
|
+
3. Would a fresh agent want it?
|
|
363
|
+
|
|
364
|
+
If yes, the agent silently submits improvement feedback through the feedback loop, contributing to collective evolution.
|
|
365
|
+
|
|
285
366
|
### Persistent Server
|
|
286
367
|
|
|
287
368
|
The server runs 24/7 in the background, surviving terminal disconnects and auto-recovering from failures. The agent operates it — you don't need to manage it.
|
|
@@ -290,7 +371,7 @@ The server runs 24/7 in the background, surviving terminal disconnects and auto-
|
|
|
290
371
|
|
|
291
372
|
| Method | Path | Description |
|
|
292
373
|
|--------|------|-------------|
|
|
293
|
-
| GET | `/health` | Health check (public, no auth) |
|
|
374
|
+
| GET | `/health` | Health check (public, no auth). Returns version, session count, scheduler status, memory usage, Node.js version |
|
|
294
375
|
| GET | `/status` | Running sessions + scheduler status |
|
|
295
376
|
| GET | `/sessions` | List all sessions (filter by `?status=`) |
|
|
296
377
|
| GET | `/sessions/tmux` | List all tmux sessions |
|
|
@@ -310,9 +391,13 @@ The server runs 24/7 in the background, surviving terminal disconnects and auto-
|
|
|
310
391
|
| POST | `/feedback/retry` | Retry un-forwarded feedback |
|
|
311
392
|
| GET | `/updates` | Check for updates |
|
|
312
393
|
| GET | `/updates/last` | Last update check result |
|
|
394
|
+
| GET | `/updates/auto` | AutoUpdater status (last check, version, next check) |
|
|
313
395
|
| GET | `/events` | Query events (`?limit=50&since=24&type=`). `since` is hours (1-720), `limit` is count (1-1000) |
|
|
314
396
|
| GET | `/quota` | Quota usage + recommendation |
|
|
397
|
+
| GET | `/capabilities` | Feature guide and metadata |
|
|
398
|
+
| GET | `/dispatches/auto` | AutoDispatcher status (last poll, pending dispatches) |
|
|
315
399
|
| GET | `/telegram/topics` | List topic-session mappings |
|
|
400
|
+
| POST | `/telegram/topics` | Programmatic topic creation |
|
|
316
401
|
| POST | `/telegram/reply/:topicId` | Send message to a topic |
|
|
317
402
|
| GET | `/telegram/topics/:topicId/messages` | Topic message history (`?limit=20`) |
|
|
318
403
|
| GET | `/evolution` | Full evolution dashboard |
|
|
@@ -412,13 +497,15 @@ Ships out of the box:
|
|
|
412
497
|
| **health-check** | Every 5 min | Haiku | Verify infrastructure health |
|
|
413
498
|
| **reflection-trigger** | Every 4h | Sonnet | Reflect on recent work |
|
|
414
499
|
| **relationship-maintenance** | Daily | Sonnet | Review stale relationships |
|
|
415
|
-
| **update-check** | Every 30 min | Haiku | Detect new Instar versions |
|
|
416
500
|
| **feedback-retry** | Every 6h | Haiku | Retry un-forwarded feedback items |
|
|
417
|
-
| **dispatch-check** | Every 30 min | Haiku | Poll for intelligence dispatches |
|
|
418
501
|
| **self-diagnosis** | Every 2h | Sonnet | Proactive infrastructure scanning |
|
|
419
502
|
| **evolution-review** | Every 6h | Sonnet | Review and implement evolution proposals |
|
|
420
503
|
| **insight-harvest** | Every 8h | Sonnet | Synthesize learnings into proposals |
|
|
421
504
|
| **commitment-check** | Every 4h | Haiku | Surface overdue action items |
|
|
505
|
+
| ~~update-check~~ | -- | -- | *Disabled* -- superseded by [AutoUpdater](#autoupdater) |
|
|
506
|
+
| ~~dispatch-check~~ | -- | -- | *Disabled* -- superseded by [AutoDispatcher](#autodispatcher) |
|
|
507
|
+
|
|
508
|
+
`update-check` and `dispatch-check` still exist in jobs.json for backward compatibility but are disabled by default. Their functionality is now handled by built-in server components that run without spawning Claude sessions.
|
|
422
509
|
|
|
423
510
|
These give the agent a **circadian rhythm** -- regular self-maintenance, evolution, and growth without user intervention.
|
|
424
511
|
|
|
@@ -485,6 +572,18 @@ Instead of per-action permission prompts, Instar pushes security to a higher lev
|
|
|
485
572
|
- Grounding hooks force identity re-read before external communication
|
|
486
573
|
- Session-start hooks inject safety context into every new session
|
|
487
574
|
|
|
575
|
+
**Network and process hardening:**
|
|
576
|
+
- CORS restricted to localhost only
|
|
577
|
+
- Server binds `127.0.0.1` by default -- not exposed to the network
|
|
578
|
+
- Shell injection mitigated via temp files instead of shell interpolation
|
|
579
|
+
- Cryptographic UUIDs (`crypto.randomUUID()`) instead of `Math.random()`
|
|
580
|
+
- Atomic file writes prevent data corruption on crash
|
|
581
|
+
- Bot token redaction in error messages and logs
|
|
582
|
+
- Feedback webhook disabled by default (opt-in)
|
|
583
|
+
- Rate limiting on session spawn (10 requests per 60 seconds sliding window)
|
|
584
|
+
- Request timeout middleware (configurable, default 30s, returns 408)
|
|
585
|
+
- HMAC-SHA256 signing on feedback payloads
|
|
586
|
+
|
|
488
587
|
**Identity coherence** -- A grounded, coherent agent with clear identity (`AGENT.md`), relationship context (`USER.md`), and accumulated memory (`MEMORY.md`) makes better decisions than a stateless process approving actions one at a time. The intelligence layer IS the security layer.
|
|
489
588
|
|
|
490
589
|
**Audit trail** -- Every session runs in tmux with full output capture. Message logs, job execution history, and session output are all persisted and inspectable.
|
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/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
|