claude-nonstop 0.3.0
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/.env.example +33 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/assets/icon.jpeg +0 -0
- package/assets/screenshot.png +0 -0
- package/bin/claude-nonstop.js +1679 -0
- package/lib/config.js +163 -0
- package/lib/keychain.js +397 -0
- package/lib/platform.js +9 -0
- package/lib/reauth.js +147 -0
- package/lib/runner.js +566 -0
- package/lib/scorer.js +100 -0
- package/lib/service.js +196 -0
- package/lib/session.js +294 -0
- package/lib/tmux.js +95 -0
- package/lib/usage.js +146 -0
- package/package.json +56 -0
- package/remote/channel-manager.cjs +548 -0
- package/remote/hook-notify.cjs +504 -0
- package/remote/load-env.cjs +32 -0
- package/remote/paths.cjs +17 -0
- package/remote/start-webhook.cjs +97 -0
- package/remote/webhook.cjs +228 -0
- package/scripts/postinstall.js +40 -0
- package/slack-manifest.yaml +32 -0
package/lib/runner.js
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process runner — spawns Claude Code, monitors output for rate limits,
|
|
3
|
+
* and automatically switches accounts with session migration.
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. Spawn `claude` with CLAUDE_CONFIG_DIR pointing to selected account
|
|
7
|
+
* 2. Pipe stdout/stderr through to the user's terminal (real-time pass-through)
|
|
8
|
+
* 3. Simultaneously scan output for rate limit patterns
|
|
9
|
+
* 4. On rate limit detection:
|
|
10
|
+
* a. Kill the paused Claude process
|
|
11
|
+
* b. Find the active session file
|
|
12
|
+
* c. Migrate session to the next best account's config dir
|
|
13
|
+
* d. Resume with `claude --resume <sessionId>` using the new account
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as pty from 'node-pty';
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { readCredentials } from './keychain.js';
|
|
22
|
+
import { checkAllUsage } from './usage.js';
|
|
23
|
+
import { pickBestAccount, effectiveUtilization } from './scorer.js';
|
|
24
|
+
import { findLatestSession, migrateSession } from './session.js';
|
|
25
|
+
import { reauthExpiredAccounts } from './reauth.js';
|
|
26
|
+
import { CONFIG_DIR } from './config.js';
|
|
27
|
+
import { getCurrentTmuxSession } from './tmux.js';
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const HOOK_NOTIFY_PATH = path.resolve(__dirname, '..', 'remote', 'hook-notify.cjs');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Rate limit detection pattern.
|
|
34
|
+
* Claude Code outputs either:
|
|
35
|
+
* "Limit reached · resets Dec 17 at 6am (Europe/Oslo)"
|
|
36
|
+
* "You've hit your limit · resets 8am (America/Los_Angeles)"
|
|
37
|
+
*/
|
|
38
|
+
const RATE_LIMIT_PATTERN = /(?:Limit reached|You've hit your limit)\s*[·•]\s*resets\s+(.+?)(?:\s*$|\n)/im;
|
|
39
|
+
|
|
40
|
+
/** Maximum output buffer size before trimming (bytes). */
|
|
41
|
+
const OUTPUT_BUFFER_MAX = 4000;
|
|
42
|
+
/** Buffer trim target (bytes). */
|
|
43
|
+
const OUTPUT_BUFFER_TRIM = 2000;
|
|
44
|
+
/** Maximum number of account swaps before giving up. */
|
|
45
|
+
const MAX_SWAPS_DEFAULT = 5;
|
|
46
|
+
/** Message sent to auto-continue after rate-limit account switch. */
|
|
47
|
+
const RATE_LIMIT_CONTINUE_MSG = 'Continue.';
|
|
48
|
+
/** Time to wait before SIGKILL after SIGTERM (ms). */
|
|
49
|
+
const KILL_ESCALATION_DELAY = 3000;
|
|
50
|
+
/** Utilization threshold (%) at which all accounts are considered near-exhausted. */
|
|
51
|
+
const EXHAUSTION_THRESHOLD = 99;
|
|
52
|
+
/** Maximum sleep duration when waiting for a rate limit reset (6 hours). */
|
|
53
|
+
const MAX_SLEEP_MS = 6 * 60 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
// ─── ANSI Stripping ────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** Strip ANSI escape codes (colors, cursor, etc.) from PTY output. */
|
|
58
|
+
function stripAnsi(str) {
|
|
59
|
+
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07]*\x07/g, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Spawn hook-notify.cjs fire-and-forget with data on stdin.
|
|
64
|
+
*/
|
|
65
|
+
function spawnHookNotify(type, data) {
|
|
66
|
+
const child = execFile('node', [HOOK_NOTIFY_PATH, type], {
|
|
67
|
+
timeout: 15_000,
|
|
68
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
69
|
+
}, () => {});
|
|
70
|
+
child.stdin.write(JSON.stringify(data));
|
|
71
|
+
child.stdin.end();
|
|
72
|
+
child.unref();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Find the earliest reset time across all non-excluded accounts.
|
|
77
|
+
*
|
|
78
|
+
* @param {Array<{name: string, usage: object}>} accounts
|
|
79
|
+
* @param {string} [excludeName] - Account name to skip
|
|
80
|
+
* @returns {number} Milliseconds until earliest reset (0 if no reset info available)
|
|
81
|
+
*/
|
|
82
|
+
function findEarliestReset(accounts, excludeName) {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
let earliest = Infinity;
|
|
85
|
+
|
|
86
|
+
for (const a of accounts) {
|
|
87
|
+
if (a.name === excludeName) continue;
|
|
88
|
+
if (!a.usage) continue;
|
|
89
|
+
|
|
90
|
+
for (const ts of [a.usage.sessionResetsAt, a.usage.weeklyResetsAt]) {
|
|
91
|
+
if (!ts) continue;
|
|
92
|
+
const resetMs = new Date(ts).getTime();
|
|
93
|
+
if (isNaN(resetMs)) continue;
|
|
94
|
+
if (resetMs > now && resetMs < earliest) {
|
|
95
|
+
earliest = resetMs;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (earliest === Infinity) return 0;
|
|
101
|
+
return earliest - now;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Format a duration in ms to a human-readable string like "2h 15m".
|
|
106
|
+
*/
|
|
107
|
+
function formatDuration(ms) {
|
|
108
|
+
const hours = Math.floor(ms / (1000 * 60 * 60));
|
|
109
|
+
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
|
110
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
111
|
+
return `${minutes}m`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sleep for the given number of milliseconds.
|
|
116
|
+
* Interruptible: SIGINT or SIGTERM will resolve the sleep early.
|
|
117
|
+
*
|
|
118
|
+
* @param {number} ms
|
|
119
|
+
* @returns {Promise<{ interrupted: boolean }>}
|
|
120
|
+
*/
|
|
121
|
+
function sleep(ms) {
|
|
122
|
+
return new Promise(resolve => {
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
cleanup();
|
|
125
|
+
resolve({ interrupted: false });
|
|
126
|
+
}, ms);
|
|
127
|
+
|
|
128
|
+
function onSignal() {
|
|
129
|
+
cleanup();
|
|
130
|
+
resolve({ interrupted: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function cleanup() {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
process.removeListener('SIGINT', onSignal);
|
|
136
|
+
process.removeListener('SIGTERM', onSignal);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
process.on('SIGINT', onSignal);
|
|
140
|
+
process.on('SIGTERM', onSignal);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Deactivate stale channel-map entries for a tmux session.
|
|
146
|
+
* Called at startup so that reuseChannelForTmuxSession only matches
|
|
147
|
+
* entries created during the current invocation (e.g., /clear or rate-limit restart),
|
|
148
|
+
* not leftover entries from a previous run.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} tmuxSessionName - The tmux session name to match
|
|
151
|
+
* @param {string} [channelMapPath] - Path to channel-map.json (default: CONFIG_DIR/data/channel-map.json)
|
|
152
|
+
*/
|
|
153
|
+
function deactivateStaleChannels(tmuxSessionName, channelMapPath) {
|
|
154
|
+
if (!channelMapPath) {
|
|
155
|
+
channelMapPath = path.join(CONFIG_DIR, 'data', 'channel-map.json');
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
if (!fs.existsSync(channelMapPath)) return;
|
|
159
|
+
const raw = fs.readFileSync(channelMapPath, 'utf8');
|
|
160
|
+
if (!raw.trim()) return;
|
|
161
|
+
const map = JSON.parse(raw);
|
|
162
|
+
|
|
163
|
+
let changed = false;
|
|
164
|
+
for (const entry of Object.values(map)) {
|
|
165
|
+
if (entry.tmuxSession === tmuxSessionName && entry.active) {
|
|
166
|
+
entry.active = false;
|
|
167
|
+
changed = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (changed) {
|
|
172
|
+
const dir = path.dirname(channelMapPath);
|
|
173
|
+
const tmpFile = path.join(dir, `.channel-map.${process.pid}.${Date.now()}.tmp`);
|
|
174
|
+
fs.writeFileSync(tmpFile, JSON.stringify(map, null, 2), { mode: 0o600 });
|
|
175
|
+
fs.renameSync(tmpFile, channelMapPath);
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// Non-fatal — channel reuse is a convenience, not critical
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run Claude Code with automatic account switching.
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} claudeArgs - Arguments to pass to `claude`
|
|
186
|
+
* @param {{ name: string, configDir: string }} selectedAccount - Account to use
|
|
187
|
+
* @param {Array<{ name: string, configDir: string }>} allAccounts - All registered accounts
|
|
188
|
+
* @param {{ maxSwaps?: number, remoteAccess?: boolean }} options - Runner options
|
|
189
|
+
*/
|
|
190
|
+
export async function run(claudeArgs, selectedAccount, allAccounts, options = {}) {
|
|
191
|
+
// Scale swap budget with account count — with N accounts, you may need
|
|
192
|
+
// N-1 swaps to try them all before exhaustion triggers the sleep mechanism.
|
|
193
|
+
// The * 2 multiplier allows for accounts recovering mid-session (5-hour resets).
|
|
194
|
+
const maxSwaps = options.maxSwaps ?? Math.max(MAX_SWAPS_DEFAULT, allAccounts.length * 2);
|
|
195
|
+
const remoteAccess = options.remoteAccess ?? false;
|
|
196
|
+
let currentAccount = selectedAccount;
|
|
197
|
+
let swapCount = 0;
|
|
198
|
+
let sessionId = extractResumeSessionId(claudeArgs);
|
|
199
|
+
|
|
200
|
+
// Deactivate stale channel entries from previous invocations so that
|
|
201
|
+
// reuseChannelForTmuxSession only matches entries from this run
|
|
202
|
+
// (i.e., /clear or rate-limit restarts within the same tmux session).
|
|
203
|
+
if (remoteAccess) {
|
|
204
|
+
deactivateStaleChannels(getCurrentTmuxSession());
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
while (swapCount <= maxSwaps) {
|
|
208
|
+
const result = await runOnce(claudeArgs, currentAccount, sessionId, { remoteAccess });
|
|
209
|
+
|
|
210
|
+
if (result.exitCode !== null && !result.rateLimitDetected) {
|
|
211
|
+
// Normal exit — propagate the exit code
|
|
212
|
+
process.exitCode = result.exitCode;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!result.rateLimitDetected) {
|
|
217
|
+
// Process ended without rate limit (e.g., signal)
|
|
218
|
+
process.exitCode = result.exitCode ?? 1;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Rate limit detected — attempt swap
|
|
223
|
+
swapCount++;
|
|
224
|
+
console.error(`\n[claude-nonstop] Rate limit detected on "${currentAccount.name}" (swap ${swapCount}/${maxSwaps})`);
|
|
225
|
+
|
|
226
|
+
if (swapCount > maxSwaps) {
|
|
227
|
+
console.error('[claude-nonstop] Maximum swap attempts reached. All accounts may be rate-limited.');
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Find the session to migrate
|
|
233
|
+
const cwd = process.cwd();
|
|
234
|
+
const session = result.sessionId
|
|
235
|
+
? { sessionId: result.sessionId }
|
|
236
|
+
: findLatestSession(currentAccount.configDir, cwd);
|
|
237
|
+
|
|
238
|
+
if (!session) {
|
|
239
|
+
console.error('[claude-nonstop] Could not find session to migrate. Starting fresh on new account.');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Pick the next best account
|
|
243
|
+
const accountsWithTokens = allAccounts.map(a => ({
|
|
244
|
+
...a,
|
|
245
|
+
token: readCredentials(a.configDir).token,
|
|
246
|
+
})).filter(a => a.token);
|
|
247
|
+
|
|
248
|
+
let accountsWithUsage = await checkAllUsage(accountsWithTokens);
|
|
249
|
+
const hasPriorities = accountsWithUsage.some(a => a.priority != null);
|
|
250
|
+
let best = pickBestAccount(accountsWithUsage, currentAccount.name, { usePriority: hasPriorities });
|
|
251
|
+
|
|
252
|
+
// If best candidate is near-exhausted, sleep until earliest reset instead of thrashing.
|
|
253
|
+
// Include all accounts (even current) when finding reset times — after sleeping,
|
|
254
|
+
// any account may have recovered, including the one that just hit the limit.
|
|
255
|
+
//
|
|
256
|
+
// TODO: For remote mode, consider an event-driven approach instead of blocking sleep:
|
|
257
|
+
// 1. Notify Slack and save session state to disk
|
|
258
|
+
// 2. Exit the runner cleanly
|
|
259
|
+
// 3. Slack bot schedules a re-launch at the reset time (or user sends !resume)
|
|
260
|
+
// This would free the tmux pane instead of holding it for hours.
|
|
261
|
+
if (best && effectiveUtilization(best.account.usage) >= EXHAUSTION_THRESHOLD) {
|
|
262
|
+
const sleepMs = findEarliestReset(accountsWithUsage);
|
|
263
|
+
if (sleepMs > 0) {
|
|
264
|
+
const clampedMs = Math.min(sleepMs, MAX_SLEEP_MS);
|
|
265
|
+
const resetDate = new Date(Date.now() + clampedMs);
|
|
266
|
+
console.error(`[claude-nonstop] All accounts near limit. Sleeping until ${resetDate.toLocaleTimeString()} (${formatDuration(clampedMs)})...`);
|
|
267
|
+
|
|
268
|
+
if (remoteAccess) {
|
|
269
|
+
spawnHookNotify('sleep-until-reset', {
|
|
270
|
+
session_id: sessionId || null,
|
|
271
|
+
cwd: process.cwd(),
|
|
272
|
+
current_account: currentAccount.name,
|
|
273
|
+
sleep_ms: clampedMs,
|
|
274
|
+
reset_at: resetDate.toISOString(),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const { interrupted } = await sleep(clampedMs);
|
|
279
|
+
if (interrupted) {
|
|
280
|
+
console.error('\n[claude-nonstop] Sleep interrupted by signal. Exiting.');
|
|
281
|
+
process.exitCode = 130;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
console.error('[claude-nonstop] Sleep complete. Re-checking account usage...');
|
|
286
|
+
|
|
287
|
+
// Re-fetch usage after sleeping — any account may have recovered,
|
|
288
|
+
// including the current one, so don't exclude it from the pick.
|
|
289
|
+
const refreshedTokens = allAccounts.map(a => ({
|
|
290
|
+
...a,
|
|
291
|
+
token: readCredentials(a.configDir).token,
|
|
292
|
+
})).filter(a => a.token);
|
|
293
|
+
accountsWithUsage = await checkAllUsage(refreshedTokens);
|
|
294
|
+
best = pickBestAccount(accountsWithUsage, undefined, { usePriority: hasPriorities });
|
|
295
|
+
|
|
296
|
+
if (remoteAccess) {
|
|
297
|
+
spawnHookNotify('sleep-wake', {
|
|
298
|
+
session_id: sessionId || null,
|
|
299
|
+
cwd: process.cwd(),
|
|
300
|
+
current_account: currentAccount.name,
|
|
301
|
+
best_account: best?.account?.name || null,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Sleep-then-swap doesn't count against the swap budget — the sleep
|
|
306
|
+
// itself is the mechanism to avoid thrashing, so this is a "free" swap.
|
|
307
|
+
swapCount--;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// If no accounts available, check if auth errors are the cause and attempt re-auth
|
|
312
|
+
if (!best && !remoteAccess) {
|
|
313
|
+
const authErrors = accountsWithUsage.filter(a =>
|
|
314
|
+
a.name !== currentAccount.name && a.usage?.error === 'HTTP 401'
|
|
315
|
+
);
|
|
316
|
+
if (authErrors.length > 0) {
|
|
317
|
+
console.error('[claude-nonstop] Some accounts have expired tokens. Attempting re-auth...');
|
|
318
|
+
const refreshed = await reauthExpiredAccounts(authErrors);
|
|
319
|
+
if (refreshed.length > 0) {
|
|
320
|
+
// Re-read credentials and re-check usage
|
|
321
|
+
const updatedAccounts = allAccounts.map(a => ({
|
|
322
|
+
...a,
|
|
323
|
+
token: readCredentials(a.configDir).token,
|
|
324
|
+
})).filter(a => a.token);
|
|
325
|
+
accountsWithUsage = await checkAllUsage(updatedAccounts);
|
|
326
|
+
best = pickBestAccount(accountsWithUsage, currentAccount.name, { usePriority: hasPriorities });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!best) {
|
|
332
|
+
console.error('[claude-nonstop] No alternative accounts available.');
|
|
333
|
+
process.exitCode = 1;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const nextAccount = best.account;
|
|
338
|
+
console.error(`[claude-nonstop] Switching to "${nextAccount.name}" (${best.reason})`);
|
|
339
|
+
|
|
340
|
+
// Notify Slack about account switch (fire-and-forget)
|
|
341
|
+
if (remoteAccess) {
|
|
342
|
+
spawnHookNotify('account-switch', {
|
|
343
|
+
session_id: sessionId || null,
|
|
344
|
+
cwd: process.cwd(),
|
|
345
|
+
from_account: currentAccount.name,
|
|
346
|
+
to_account: nextAccount.name,
|
|
347
|
+
reason: best.reason,
|
|
348
|
+
swap_count: swapCount,
|
|
349
|
+
max_swaps: maxSwaps,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Migrate session if we have one
|
|
354
|
+
if (session) {
|
|
355
|
+
const migration = migrateSession(
|
|
356
|
+
currentAccount.configDir,
|
|
357
|
+
nextAccount.configDir,
|
|
358
|
+
cwd,
|
|
359
|
+
session.sessionId
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (migration.success) {
|
|
363
|
+
sessionId = session.sessionId;
|
|
364
|
+
console.error(`[claude-nonstop] Session ${sessionId} migrated successfully`);
|
|
365
|
+
} else {
|
|
366
|
+
console.error(`[claude-nonstop] Session migration failed: ${migration.error}`);
|
|
367
|
+
console.error('[claude-nonstop] Starting fresh session on new account');
|
|
368
|
+
sessionId = null;
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
sessionId = null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Update args for resume if we have a session — include continuation
|
|
375
|
+
// message so Claude picks up immediately instead of waiting for input
|
|
376
|
+
if (sessionId) {
|
|
377
|
+
claudeArgs = buildResumeArgs(claudeArgs, sessionId, RATE_LIMIT_CONTINUE_MSG);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
currentAccount = nextAccount;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Run Claude once, monitoring for rate limits.
|
|
386
|
+
*
|
|
387
|
+
* @returns {Promise<{ exitCode: number|null, rateLimitDetected: boolean, resetTime: string|null, sessionId: string|null }>}
|
|
388
|
+
*/
|
|
389
|
+
function runOnce(claudeArgs, account, existingSessionId, options = {}) {
|
|
390
|
+
return new Promise((resolve) => {
|
|
391
|
+
const env = {
|
|
392
|
+
...process.env,
|
|
393
|
+
CLAUDE_CONFIG_DIR: account.configDir,
|
|
394
|
+
FORCE_COLOR: '1',
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Strip CLAUDECODE so spawned claude works from inside a Claude Code session
|
|
398
|
+
delete env.CLAUDECODE;
|
|
399
|
+
|
|
400
|
+
if (options.remoteAccess) {
|
|
401
|
+
env.CLAUDE_REMOTE_ACCESS = 'true';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const child = pty.spawn('claude', claudeArgs, {
|
|
405
|
+
name: 'xterm-256color',
|
|
406
|
+
cols: process.stdout.columns || 80,
|
|
407
|
+
rows: process.stdout.rows || 24,
|
|
408
|
+
cwd: process.cwd(),
|
|
409
|
+
env,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Resize PTY when the real terminal resizes
|
|
413
|
+
const onResize = () => {
|
|
414
|
+
try { child.resize(process.stdout.columns, process.stdout.rows); } catch {}
|
|
415
|
+
};
|
|
416
|
+
process.stdout.on('resize', onResize);
|
|
417
|
+
|
|
418
|
+
// Forward stdin to the PTY (resume in case it was paused by a previous runOnce)
|
|
419
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
420
|
+
process.stdin.resume();
|
|
421
|
+
const onStdinData = (data) => child.write(data);
|
|
422
|
+
process.stdin.on('data', onStdinData);
|
|
423
|
+
process.stdin.on('error', () => {});
|
|
424
|
+
|
|
425
|
+
let rateLimitDetected = false;
|
|
426
|
+
let resetTime = null;
|
|
427
|
+
let outputBuffer = '';
|
|
428
|
+
|
|
429
|
+
child.onData((data) => {
|
|
430
|
+
process.stdout.write(data);
|
|
431
|
+
|
|
432
|
+
// Scan for rate limit patterns in rolling buffer
|
|
433
|
+
outputBuffer += data;
|
|
434
|
+
if (outputBuffer.length > OUTPUT_BUFFER_MAX) {
|
|
435
|
+
outputBuffer = outputBuffer.slice(-OUTPUT_BUFFER_TRIM);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (rateLimitDetected) return;
|
|
439
|
+
|
|
440
|
+
// Primary pattern: "Limit reached · resets ..."
|
|
441
|
+
// Strip ANSI codes before matching — FORCE_COLOR=1 means output has styling
|
|
442
|
+
const match = RATE_LIMIT_PATTERN.exec(stripAnsi(outputBuffer));
|
|
443
|
+
if (match) {
|
|
444
|
+
rateLimitDetected = true;
|
|
445
|
+
resetTime = match[1].trim();
|
|
446
|
+
child.kill('SIGTERM');
|
|
447
|
+
setTimeout(() => {
|
|
448
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
449
|
+
}, KILL_ESCALATION_DELAY);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Forward signals to child
|
|
455
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
456
|
+
const signalHandlers = {};
|
|
457
|
+
let cleaned = false;
|
|
458
|
+
|
|
459
|
+
function cleanup() {
|
|
460
|
+
if (cleaned) return;
|
|
461
|
+
cleaned = true;
|
|
462
|
+
|
|
463
|
+
for (const sig of signals) {
|
|
464
|
+
process.removeListener(sig, signalHandlers[sig]);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
process.stdin.removeListener('data', onStdinData);
|
|
468
|
+
process.stdin.pause();
|
|
469
|
+
if (process.stdin.isTTY) {
|
|
470
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
471
|
+
}
|
|
472
|
+
process.stdout.removeListener('resize', onResize);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
for (const sig of signals) {
|
|
476
|
+
const handler = () => {
|
|
477
|
+
if (!rateLimitDetected) {
|
|
478
|
+
try { child.kill(sig); } catch {}
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
signalHandlers[sig] = handler;
|
|
482
|
+
process.on(sig, handler);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Single onExit handler: cleanup + resolve
|
|
486
|
+
child.onExit(({ exitCode }) => {
|
|
487
|
+
cleanup();
|
|
488
|
+
|
|
489
|
+
resolve({
|
|
490
|
+
exitCode: exitCode ?? null,
|
|
491
|
+
rateLimitDetected,
|
|
492
|
+
resetTime,
|
|
493
|
+
sessionId: existingSessionId,
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Extract --resume session ID from claude args if present.
|
|
501
|
+
*/
|
|
502
|
+
function extractResumeSessionId(args) {
|
|
503
|
+
const idx = args.indexOf('--resume');
|
|
504
|
+
if (idx !== -1 && idx + 1 < args.length) {
|
|
505
|
+
return args[idx + 1];
|
|
506
|
+
}
|
|
507
|
+
// Also check -r shorthand
|
|
508
|
+
const idxR = args.indexOf('-r');
|
|
509
|
+
if (idxR !== -1 && idxR + 1 < args.length) {
|
|
510
|
+
return args[idxR + 1];
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Known Claude CLI flags that take a value argument. */
|
|
516
|
+
const FLAGS_WITH_VALUES = new Set([
|
|
517
|
+
'--append-system-prompt', '--model', '-m',
|
|
518
|
+
'--allowedTools', '--disallowedTools',
|
|
519
|
+
]);
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Build new claude args with --resume flag.
|
|
523
|
+
* Replaces existing --resume if present, otherwise prepends it.
|
|
524
|
+
*
|
|
525
|
+
* When continueMessage is provided (rate-limit swap), strips positional args
|
|
526
|
+
* (the original user prompt and any previous continue message) so Claude
|
|
527
|
+
* receives only the continuation prompt and picks up where it left off.
|
|
528
|
+
*/
|
|
529
|
+
function buildResumeArgs(originalArgs, sessionId, continueMessage) {
|
|
530
|
+
const args = [...originalArgs];
|
|
531
|
+
|
|
532
|
+
// Remove existing --resume or -r flags
|
|
533
|
+
for (const flag of ['--resume', '-r']) {
|
|
534
|
+
const idx = args.indexOf(flag);
|
|
535
|
+
if (idx !== -1) {
|
|
536
|
+
args.splice(idx, 2); // Remove flag and its value
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (continueMessage) {
|
|
541
|
+
// Strip positional args — keep only flags and their values
|
|
542
|
+
const flagsOnly = [];
|
|
543
|
+
for (let i = 0; i < args.length; i++) {
|
|
544
|
+
if (args[i].startsWith('-')) {
|
|
545
|
+
flagsOnly.push(args[i]);
|
|
546
|
+
if (FLAGS_WITH_VALUES.has(args[i]) && i + 1 < args.length) {
|
|
547
|
+
flagsOnly.push(args[++i]);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
flagsOnly.unshift('--resume', sessionId);
|
|
552
|
+
flagsOnly.push(continueMessage);
|
|
553
|
+
return flagsOnly;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Prepend --resume
|
|
557
|
+
args.unshift('--resume', sessionId);
|
|
558
|
+
return args;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export {
|
|
562
|
+
stripAnsi, extractResumeSessionId, buildResumeArgs, RATE_LIMIT_PATTERN,
|
|
563
|
+
RATE_LIMIT_CONTINUE_MSG, FLAGS_WITH_VALUES,
|
|
564
|
+
findEarliestReset, formatDuration, sleep, deactivateStaleChannels,
|
|
565
|
+
EXHAUSTION_THRESHOLD, MAX_SLEEP_MS,
|
|
566
|
+
};
|
package/lib/scorer.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account scoring and selection.
|
|
3
|
+
*
|
|
4
|
+
* Picks the best account based on usage — lowest effective utilization wins.
|
|
5
|
+
* Effective utilization = max(sessionPercent, weeklyPercent) so we avoid
|
|
6
|
+
* accounts that are near either limit.
|
|
7
|
+
*
|
|
8
|
+
* When usePriority is true, accounts with lower priority numbers are preferred
|
|
9
|
+
* over accounts with lower utilization. Accounts at or above 98% utilization
|
|
10
|
+
* are considered "near-exhausted" and skipped in favor of the next priority.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PRIORITY_THRESHOLD = 98;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pick the best account from a list of accounts with usage data.
|
|
17
|
+
*
|
|
18
|
+
* @param {Array<{name: string, configDir: string, token: string, usage: object, priority?: number}>} accounts
|
|
19
|
+
* @param {string} [excludeName] - Account name to exclude (e.g., the one that just hit a limit)
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {boolean} [options.usePriority=false] - When true, prefer accounts by priority number
|
|
22
|
+
* @returns {{ account: object, reason: string } | null}
|
|
23
|
+
*/
|
|
24
|
+
export function pickBestAccount(accounts, excludeName, options = {}) {
|
|
25
|
+
const candidates = accounts.filter(a => {
|
|
26
|
+
if (a.name === excludeName) return false;
|
|
27
|
+
if (!a.token) return false;
|
|
28
|
+
if (a.usage?.error) return false;
|
|
29
|
+
return true;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (candidates.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
if (options.usePriority) {
|
|
35
|
+
// Priority-aware sorting:
|
|
36
|
+
// 1. Non-exhausted (< 98%) before exhausted (>= 98%)
|
|
37
|
+
// 2. Within each group: lower priority number first (nulls last)
|
|
38
|
+
// 3. Tiebreaker: lower utilization first
|
|
39
|
+
candidates.sort((a, b) => {
|
|
40
|
+
const aUtil = effectiveUtilization(a.usage);
|
|
41
|
+
const bUtil = effectiveUtilization(b.usage);
|
|
42
|
+
const aExhausted = aUtil >= PRIORITY_THRESHOLD;
|
|
43
|
+
const bExhausted = bUtil >= PRIORITY_THRESHOLD;
|
|
44
|
+
|
|
45
|
+
// Non-exhausted accounts always come first
|
|
46
|
+
if (aExhausted !== bExhausted) return aExhausted ? 1 : -1;
|
|
47
|
+
|
|
48
|
+
// Within same exhaustion group: sort by priority (lower = better, null = last)
|
|
49
|
+
const aPri = a.priority ?? Infinity;
|
|
50
|
+
const bPri = b.priority ?? Infinity;
|
|
51
|
+
if (aPri !== bPri) return aPri - bPri;
|
|
52
|
+
|
|
53
|
+
// Same priority: sort by utilization
|
|
54
|
+
return aUtil - bUtil;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const best = candidates[0];
|
|
58
|
+
const pri = best.priority != null ? `, priority: ${best.priority}` : '';
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
account: best,
|
|
62
|
+
reason: `priority selection (session: ${best.usage.sessionPercent}%, weekly: ${best.usage.weeklyPercent}%${pri})`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Default: sort by effective utilization (ascending — lowest usage first)
|
|
67
|
+
candidates.sort((a, b) => {
|
|
68
|
+
const aUtil = effectiveUtilization(a.usage);
|
|
69
|
+
const bUtil = effectiveUtilization(b.usage);
|
|
70
|
+
return aUtil - bUtil;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const best = candidates[0];
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
account: best,
|
|
77
|
+
reason: `lowest utilization (session: ${best.usage.sessionPercent}%, weekly: ${best.usage.weeklyPercent}%)`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pick the best account using priority hierarchy.
|
|
83
|
+
* Convenience wrapper for `use --priority`.
|
|
84
|
+
*
|
|
85
|
+
* @param {Array} accounts - Accounts with usage data
|
|
86
|
+
* @returns {{ account: object, reason: string } | null}
|
|
87
|
+
*/
|
|
88
|
+
export function pickByPriority(accounts) {
|
|
89
|
+
return pickBestAccount(accounts, undefined, { usePriority: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Calculate effective utilization — the higher of session or weekly.
|
|
94
|
+
*/
|
|
95
|
+
export function effectiveUtilization(usage) {
|
|
96
|
+
if (!usage) return 100;
|
|
97
|
+
return Math.max(usage.sessionPercent || 0, usage.weeklyPercent || 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { PRIORITY_THRESHOLD };
|