ccdeck 3.43.4 → 3.44.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/README.md +69 -17
- package/THIRD_PARTY_NOTICES.md +43 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +16 -1
- package/dist/web/assets/{BrowserWatchModal-nOxhGOO4.js → BrowserWatchModal-DMIUygWz.js} +1 -1
- package/dist/web/assets/{RadioPanel-BLfpQ1Iw.js → RadioPanel-DNB_AHSs.js} +1 -1
- package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
- package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
- package/dist/web/assets/app-CQIstkMF.js +318 -0
- package/dist/web/assets/app-Ch-0CYlK.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-BQbOfxbL.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +34 -0
- package/src/server/account-notify.mjs +206 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +48 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +115 -14
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +16 -5
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/index.mjs +17 -1
- package/src/server/installer.mjs +149 -8
- package/src/server/lan-engine.mjs +2 -2
- package/src/server/lan-sync.mjs +3 -3
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/pinned-build.mjs +5 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +7 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/usage-day.mjs +1 -1
- package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
- package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
- package/dist/web/assets/app-BiJ8TfUl.css +0 -1
- package/dist/web/assets/app-DBsNB-2y.js +0 -312
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./app-
|
|
1
|
+
import"./app-CQIstkMF.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
|
package/dist/web/index.html
CHANGED
|
@@ -77,9 +77,9 @@
|
|
|
77
77
|
document.documentElement.setAttribute("data-density", density === "tightest" || density === "tighter" || density === "compact" || density === "comfortable" ? density : "default");
|
|
78
78
|
})();
|
|
79
79
|
</script>
|
|
80
|
-
<script type="module" crossorigin src="/assets/index-
|
|
81
|
-
<link rel="modulepreload" crossorigin href="/assets/app-
|
|
82
|
-
<link rel="stylesheet" crossorigin href="/assets/app-
|
|
80
|
+
<script type="module" crossorigin src="/assets/index-BQbOfxbL.js"></script>
|
|
81
|
+
<link rel="modulepreload" crossorigin href="/assets/app-CQIstkMF.js">
|
|
82
|
+
<link rel="stylesheet" crossorigin href="/assets/app-Ch-0CYlK.css">
|
|
83
83
|
</head>
|
|
84
84
|
<body>
|
|
85
85
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.44.0",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"esbuild": "0.25.12",
|
|
83
83
|
"hls.js": "^1.7.3",
|
|
84
84
|
"hpack.js": "2.1.6",
|
|
85
|
+
"json-edit-react": "2.0.4",
|
|
85
86
|
"long": "5.3.2",
|
|
86
87
|
"protobufjs": "8.8.0",
|
|
87
88
|
"react": "^18.3.1",
|
package/release-notes.json
CHANGED
|
@@ -80,6 +80,40 @@
|
|
|
80
80
|
"3.43.1",
|
|
81
81
|
"3.43.3"
|
|
82
82
|
],
|
|
83
|
+
"3.44.0": [
|
|
84
|
+
{
|
|
85
|
+
"title": "⛔ A turn that stops on an error now shows as stopped",
|
|
86
|
+
"body": "When Claude Code ends a turn on an API error, such as an expired login or a usage limit, it sends StopFailure instead of Stop. The deck now adds StopFailure to the hooks it writes in Claude Code's settings.json, but only when every Claude Code it finds is 2.1.78 or newer; with an older one, or one whose version it cannot read, nothing changes. The card then says “Stopped · login expired”, “Stopped · usage limit” or “Stopped · API error”, and the sessions one expired login stops together arrive as one notification that names them. Each needs a “continue” once a working login is active."
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"title": "🔑 Auto-switch leaves an expired login, and nothing switches onto one",
|
|
90
|
+
"body": "When Claude Code has signed a login out and claude-swap's saved copy is dead too, Auto-switch now moves to another account under your home and weekly-reset rules, and its notification says why. Neither Auto-switch nor Switch moves onto a login that is already known to be expired: that account's row says its login expired, with Sign in again, and nothing moves."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"title": "💲 Usage totals say how complete they are",
|
|
94
|
+
"body": "The usage headline is always shown now, by the rule History and the model table already used: an exact total when every cost is known, a known $0 included; the known part with “+” when some tokens could not be priced; and “not priced”, never a bare $0, when no cost is known. Sessions whose cost is a known zero show it too."
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"title": "📡 Radar: a resizable inspector, prompt previews and a JSON view",
|
|
98
|
+
"body": "Radar's inspector can be resized and shows prompt previews, highlights and each record as a JSON tree, beside the File view. Settings › Radar has a new Monitor automatically switch. It is off, so Radar still watches only after you press Start, as before; turn it on to have the deck start monitoring by itself."
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"title": "🧰 Tool activity opens over the deck, with the first tool's details open",
|
|
102
|
+
"body": "Tool activity now opens as a dialog over the deck instead of a page of its own, with the first tool selected and its details beside the list. Escape closes the details first, then the dialog. On a phone the details open as a sheet."
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"title": "🧩 Tool activity counts skills, and the slash commands you type if you opt in",
|
|
106
|
+
"body": "Tool activity's Skills tab ranks the skills your Claude Code and Codex agents used. Counting the slash commands you type needs Claude Code's UserPromptExpansion hook, which the deck adds to settings.json only when you start it with AGENTS_DECK_SKILL_HOOK=1 and every Claude Code it finds is 2.1.116 or newer. Without that variable nothing in settings.json changes."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"title": "▶️ Continue a background session that stopped",
|
|
110
|
+
"body": "A background session that stopped on an error has a Continue button on its card and panel: the deck stops it if Claude Code still lists it, then resumes it in its own folder with “Continue from where you left off.” Continue stopped background sessions, in Settings › General, presses it for you once per stop. It is off until you turn it on."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"title": "⌨️ ccdeck commands for your terminal",
|
|
114
|
+
"body": "ccdeck accounts, ccdeck switch, ccdeck usage, ccdeck sessions and ccdeck open work from any terminal. ccdeck switch --best goes where Auto-switch would, and every switch follows the deck's own rules. Over SSH, ccdeck open prints the line that opens the deck on your own computer. Each command takes --json for scripts."
|
|
115
|
+
}
|
|
116
|
+
],
|
|
83
117
|
"3.43.4": [
|
|
84
118
|
{
|
|
85
119
|
"title": "🔐 Local network no longer sends the login a deck is signed in to",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Four notifications about the accounts the deck is on, and the rules for when
|
|
2
2
|
// each is said.
|
|
3
3
|
//
|
|
4
4
|
// SWAP the deck's own auto-switch tick moved the live Claude account.
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
// THRESHOLD a quota window of the account a provider is LIVE on reached 90%,
|
|
9
9
|
// and once more at 100%.
|
|
10
10
|
// RESET a window of the live account that had reached 90% is new again.
|
|
11
|
+
// STOPPED Claude Code sessions ended a turn on an expired login or a usage
|
|
12
|
+
// limit (StopFailure, stop-failure.mjs). Said once per incident —
|
|
13
|
+
// every session on the machine shares the login, so they stop
|
|
14
|
+
// together — with how many stopped and that each needs a
|
|
15
|
+
// "continue" once a working login is active.
|
|
11
16
|
//
|
|
12
17
|
// SPARSE BY CONSTRUCTION. Each window is a record keyed by provider, account
|
|
13
18
|
// and window, holding the reset instant it is about and the levels already said
|
|
@@ -26,9 +31,17 @@
|
|
|
26
31
|
// never saw because the tick saw it first is written down from the tick, so the
|
|
27
32
|
// account does not announce it later when it is live again.
|
|
28
33
|
//
|
|
34
|
+
// ONE INCIDENT, ONE NOTIFICATION, by the same rule. A login that dies makes the
|
|
35
|
+
// deck run a tick at once (cswap-auto-loop.mjs `tickSoon`), so while the loop
|
|
36
|
+
// and the swap switch are on the stopped notice waits for the first tick that
|
|
37
|
+
// began after the incident did: a switch says both in one notification —
|
|
38
|
+
// "Switched to … · 3 sessions stopped, type “continue” in each" — and anything
|
|
39
|
+
// else lets the stopped notice go on its own.
|
|
40
|
+
//
|
|
29
41
|
// Pure apart from the factory at the bottom, whose clock, disk and OS call are
|
|
30
42
|
// injected — the shape block-notify.mjs uses, for the reason it gives: what the
|
|
31
43
|
// suite cannot run is what drifts.
|
|
44
|
+
import { switchCanCure } from "./stop-failure.mjs";
|
|
32
45
|
|
|
33
46
|
/** The levels a window is announced at, lowest first. */
|
|
34
47
|
export const LEVELS = Object.freeze([90, 100]);
|
|
@@ -296,6 +309,11 @@ export function swapNotice({ toName, fromName, used, threshold, trigger = null,
|
|
|
296
309
|
const verb = trigger === "home" ? "Back on" : "Switched to";
|
|
297
310
|
if (!fromName) return { who: SWAP_WHO, body: `${verb} ${to}` };
|
|
298
311
|
const from = cut(fromName, NAME_MAX);
|
|
312
|
+
// The deck's own failover (autoswitch-failover.mjs) moved for one reason
|
|
313
|
+
// only, and it is the one the user has to act on: sign in again.
|
|
314
|
+
if (trigger === "login-expired") {
|
|
315
|
+
return { who: SWAP_WHO, body: `Switched to ${to} · ${from}'s login expired, sign in again to use it` };
|
|
316
|
+
}
|
|
299
317
|
if (used != null && used >= 100) return { who: SWAP_WHO, body: `${verb} ${to} · ${from} reached 100%` };
|
|
300
318
|
if (used != null && typeof threshold === "number" && used >= threshold) {
|
|
301
319
|
return { who: SWAP_WHO, body: `${verb} ${to} · ${from} reached ${pctLabel(threshold)}%` };
|
|
@@ -307,6 +325,84 @@ export function swapNotice({ toName, fromName, used, threshold, trigger = null,
|
|
|
307
325
|
return { who: SWAP_WHO, body: `Switched to ${to} from ${from}` };
|
|
308
326
|
}
|
|
309
327
|
|
|
328
|
+
/** How long a stop gathers the rest of its incident before it is said, when no
|
|
329
|
+
* tick is coming to say it with: sessions sharing a login stop within seconds
|
|
330
|
+
* of each other, each at its own next request. */
|
|
331
|
+
export const STOP_GATHER_MS = 5_000;
|
|
332
|
+
|
|
333
|
+
/** The longest a stopped notice waits for a tick to say whether it switched.
|
|
334
|
+
* The tick is run at once, and one is allowed two minutes. */
|
|
335
|
+
export const STOP_HOLD_MS = 150_000;
|
|
336
|
+
|
|
337
|
+
/** A stop this long after the incident's newest is a new incident. Long enough
|
|
338
|
+
* for the stragglers — a session four minutes into a test run stops when the
|
|
339
|
+
* run finishes — and short against a login that dies again tomorrow. */
|
|
340
|
+
export const STOP_INCIDENT_MS = 10 * 60_000;
|
|
341
|
+
|
|
342
|
+
/** How many sessions an incident remembers. `POST /api/event` takes a post
|
|
343
|
+
* without the deck's token, so the count of ids is bounded like every other
|
|
344
|
+
* set fed from it. */
|
|
345
|
+
export const STOP_SESSIONS_MAX = 256;
|
|
346
|
+
|
|
347
|
+
/** How many sessions a notice names before it counts the rest. */
|
|
348
|
+
export const STOP_NAMES_SHOWN = 3;
|
|
349
|
+
|
|
350
|
+
/** "vcrm-core", "vcrm-core and api", "vcrm-core, api, web and 2 more". */
|
|
351
|
+
function nameList(names, count) {
|
|
352
|
+
const shown = names.slice(0, STOP_NAMES_SHOWN).map(n => cut(n, NAME_MAX));
|
|
353
|
+
const more = count - shown.length;
|
|
354
|
+
if (more > 0) return `${shown.join(", ")} and ${more} more`;
|
|
355
|
+
if (shown.length <= 1) return shown[0] ?? "";
|
|
356
|
+
return `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** "3 sessions stopped" / "1 session stopped". */
|
|
360
|
+
function stoppedCount(count) {
|
|
361
|
+
return `${count} ${count === 1 ? "session" : "sessions"} stopped`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* "3 Claude sessions stopped", "Login expired · vcrm-core, api and web. Type
|
|
366
|
+
* “continue” in each once a working login is active."
|
|
367
|
+
*
|
|
368
|
+
* `kinds` is the set the incident's stops were: "login", "usage-limit" or
|
|
369
|
+
* both. What to do comes last because it is the part a person acts on, and it
|
|
370
|
+
* is the one Claude Code cannot do itself: a running session picks up a
|
|
371
|
+
* working login on its own, but the turn that failed is not run again.
|
|
372
|
+
*/
|
|
373
|
+
export function stoppedNotice({ names = [], count, kinds }) {
|
|
374
|
+
const login = kinds.has("login"), limit = kinds.has("usage-limit");
|
|
375
|
+
const what = login && limit ? "Login expired or usage limit reached" : limit ? "Usage limit reached" : "Login expired";
|
|
376
|
+
const when = login && limit
|
|
377
|
+
? "once a working account is active"
|
|
378
|
+
: limit ? "once the limit resets or another account is active" : "once a working login is active";
|
|
379
|
+
const where = nameList(names, count);
|
|
380
|
+
const each = count === 1 ? "it" : "each";
|
|
381
|
+
return {
|
|
382
|
+
who: `${count} Claude ${count === 1 ? "session" : "sessions"} stopped`,
|
|
383
|
+
body: `${what}${where ? ` · ${where}` : ""}. Type “continue” in ${each} ${when}.`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A swap notice that also carries the incident it cured: one notification for
|
|
388
|
+
* one incident, and the switch is what made "continue" worth typing. */
|
|
389
|
+
export function withStopped(notice, count) {
|
|
390
|
+
return { ...notice, body: `${notice.body} · ${stoppedCount(count)}, type “continue” in ${count === 1 ? "it" : "each"}` };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** How long one stuck login is left unsaid after it has been said once. */
|
|
394
|
+
export const STUCK_REPEAT_MS = 6 * 60 * 60 * 1000;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* "Claude auto-switch", "work@x.com's login expired · no other account is ready
|
|
398
|
+
* to switch to". The tick the failover could not finish: the user is on a dead
|
|
399
|
+
* login, and only a sign-in or another account will get them off it.
|
|
400
|
+
*/
|
|
401
|
+
export function stuckNotice({ fromName }) {
|
|
402
|
+
const from = fromName ? cut(fromName, NAME_MAX) : "The active account";
|
|
403
|
+
return { who: SWAP_WHO, body: `${from}'s login expired · no other account is ready to switch to` };
|
|
404
|
+
}
|
|
405
|
+
|
|
310
406
|
/** The records, as read back from disk: anything that is not one is dropped,
|
|
311
407
|
* and so is one nobody has read for RECORD_KEEP_MS. */
|
|
312
408
|
export function normaliseRecords(raw, now) {
|
|
@@ -339,8 +435,9 @@ function laterUnref(fn, ms) {
|
|
|
339
435
|
*
|
|
340
436
|
* notify(title, body, meta) the OS call — injected, never awaited by a poll
|
|
341
437
|
* or a tick, and its failure goes to `onError`
|
|
342
|
-
* settings() `{ swap, quota, reset }`, asked per
|
|
343
|
-
* a switch pressed mid-run takes
|
|
438
|
+
* settings() `{ swap, quota, reset, stopped }`, asked per
|
|
439
|
+
* decision, so a switch pressed mid-run takes
|
|
440
|
+
* effect at once
|
|
344
441
|
* load() / save(records) the disk, both best effort
|
|
345
442
|
* now, later the clock and the hold timer, for the suite
|
|
346
443
|
*
|
|
@@ -364,6 +461,11 @@ export function createAccountNotifier({
|
|
|
364
461
|
const held = new Map();
|
|
365
462
|
let holdTimer = null;
|
|
366
463
|
let saving = Promise.resolve();
|
|
464
|
+
/** The stop incident being gathered or held, or the one already said while
|
|
465
|
+
* it is still running: `{ firstAt, lastAt, ids, names, kinds, said, held,
|
|
466
|
+
* armed, timer }`. In memory — an incident is minutes long, and a deck restarted
|
|
467
|
+
* inside one has lost the sessions it would have counted. */
|
|
468
|
+
let incident = null;
|
|
367
469
|
|
|
368
470
|
const persist = () => {
|
|
369
471
|
const snapshot = JSON.parse(JSON.stringify(records));
|
|
@@ -381,6 +483,10 @@ export function createAccountNotifier({
|
|
|
381
483
|
|
|
382
484
|
const keyOf = (provider, accountKey, windowId) => `${provider}|${accountKey}|${windowId}`;
|
|
383
485
|
|
|
486
|
+
/** When each stuck login was last said, by account. In memory: a restart may
|
|
487
|
+
* say one once more, and every tick after it says nothing for a while. */
|
|
488
|
+
const stuckSaid = new Map();
|
|
489
|
+
|
|
384
490
|
/** Write `levels` down as said for the window a held notice was about, if the
|
|
385
491
|
* record is still about that window. */
|
|
386
492
|
const markSaid = (entry, levels) => {
|
|
@@ -416,6 +522,55 @@ export function createAccountNotifier({
|
|
|
416
522
|
}, Math.max(0, due - now()));
|
|
417
523
|
};
|
|
418
524
|
|
|
525
|
+
/** Say the incident, once, unless its switch is off by now. */
|
|
526
|
+
const sayIncident = () => {
|
|
527
|
+
const inc = incident;
|
|
528
|
+
if (!inc || inc.said) return;
|
|
529
|
+
inc.said = true;
|
|
530
|
+
if (inc.timer) { clearTimeout(inc.timer); inc.timer = null; }
|
|
531
|
+
if (settings().stopped) say(stoppedNotice({ names: inc.names, count: inc.ids.size, kinds: inc.kinds }));
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* A session's turn ended on an error (StopFailure). Only the two a working
|
|
536
|
+
* login cures are an incident; they gather into one, and the first is what
|
|
537
|
+
* arms its notice — held for the tick when a tick will say what it did, and
|
|
538
|
+
* said after STOP_GATHER_MS otherwise. Every later stop of the incident is
|
|
539
|
+
* counted and says nothing: the cards show each one.
|
|
540
|
+
*/
|
|
541
|
+
function stopped({ sessionId, who, kind, at = now() } = {}) {
|
|
542
|
+
try {
|
|
543
|
+
if (!switchCanCure(kind) || typeof sessionId !== "string" || !sessionId) return;
|
|
544
|
+
if (incident && at - incident.lastAt > STOP_INCIDENT_MS) {
|
|
545
|
+
if (incident.timer) clearTimeout(incident.timer);
|
|
546
|
+
incident = null;
|
|
547
|
+
}
|
|
548
|
+
if (!incident) {
|
|
549
|
+
incident = { firstAt: at, lastAt: at, ids: new Set(), names: [], kinds: new Set(), said: false, held: false, armed: false, timer: null };
|
|
550
|
+
}
|
|
551
|
+
const inc = incident;
|
|
552
|
+
inc.lastAt = Math.max(inc.lastAt, at);
|
|
553
|
+
inc.kinds.add(kind);
|
|
554
|
+
if (!inc.ids.has(sessionId) && inc.ids.size < STOP_SESSIONS_MAX) {
|
|
555
|
+
inc.ids.add(sessionId);
|
|
556
|
+
const name = typeof who === "string" && who ? who : sessionId.slice(0, 8);
|
|
557
|
+
if (!inc.names.includes(name)) inc.names.push(name);
|
|
558
|
+
}
|
|
559
|
+
if (inc.said || inc.armed) return;
|
|
560
|
+
inc.armed = true;
|
|
561
|
+
inc.held = autoOn && settings().swap;
|
|
562
|
+
inc.timer = later(() => { inc.timer = null; if (incident === inc) sayIncident(); }, inc.held ? STOP_HOLD_MS : STOP_GATHER_MS);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
onError?.(err);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** The incident a finished tick answers for: one still held, and begun no
|
|
569
|
+
* later than the tick — a tick that started before the login died says
|
|
570
|
+
* nothing about it. */
|
|
571
|
+
const incidentFor = startedAt =>
|
|
572
|
+
incident && !incident.said && incident.held && (startedAt == null || startedAt >= incident.firstAt) ? incident : null;
|
|
573
|
+
|
|
419
574
|
/** Should a threshold notice for this window wait for the next tick? */
|
|
420
575
|
const holdFor = (provider, level) =>
|
|
421
576
|
provider === "claude" && autoOn && settings().swap && (policy == null || level >= policy);
|
|
@@ -460,19 +615,47 @@ export function createAccountNotifier({
|
|
|
460
615
|
*
|
|
461
616
|
* `accounts` is claude-swap's roster as sequence.json has it — number to
|
|
462
617
|
* `{ email, organizationUuid, alias }` — read after the tick, for the names
|
|
463
|
-
* and the keys of the two accounts a switch moved between.
|
|
618
|
+
* and the keys of the two accounts a switch moved between. `startedAt` is
|
|
619
|
+
* when the tick began, which decides whether it can answer for a stop.
|
|
464
620
|
*/
|
|
465
|
-
async function tick(result, accounts) {
|
|
621
|
+
async function tick(result, accounts, { startedAt = null } = {}) {
|
|
466
622
|
try {
|
|
467
623
|
await ready;
|
|
468
624
|
if (!result || typeof result !== "object") return;
|
|
469
625
|
if (typeof result.threshold === "number" && Number.isFinite(result.threshold)) policy = result.threshold;
|
|
626
|
+
// A tick that did not switch lets a held stop go now, a cooldown
|
|
627
|
+
// included: the sessions are stopped already, and minutes is too long to
|
|
628
|
+
// wait to say so for a switch that may come.
|
|
629
|
+
const inc = incidentFor(startedAt);
|
|
630
|
+
// On a dead login with nowhere to go, said once and then left alone:
|
|
631
|
+
// the failover asks again every tick, and the answer will not change
|
|
632
|
+
// until somebody signs in.
|
|
633
|
+
const stuck = !result.switched && result.event === "no-switch" && result.reason === "login-expired" && result.stuck === true;
|
|
634
|
+
const stuckFrom = stuck ? accountOfRef(result.from, accounts) : null;
|
|
635
|
+
const stuckKey = stuck ? stuckFrom?.key ?? String(result.from?.number ?? "") : null;
|
|
636
|
+
const sayStuck = stuck && settings().swap && !(now() - (stuckSaid.get(stuckKey) ?? -Infinity) < STUCK_REPEAT_MS);
|
|
637
|
+
// The stuck notice carries the incident it explains, as a switch does:
|
|
638
|
+
// one notification for one dead login.
|
|
639
|
+
if (inc && !(result.switched && settings().swap) && !sayStuck) sayIncident();
|
|
470
640
|
if (!result.switched) {
|
|
471
641
|
// A cooldown is the one answer that says a switch is still coming.
|
|
472
642
|
if (result.event === "no-switch" && result.reason === "cooldown") return;
|
|
473
643
|
release([...held.values()]);
|
|
644
|
+
if (sayStuck) {
|
|
645
|
+
stuckSaid.set(stuckKey, now());
|
|
646
|
+
const notice = stuckNotice({ fromName: stuckFrom?.name ?? null });
|
|
647
|
+
const told = inc && incident === inc && !inc.said ? inc : null;
|
|
648
|
+
if (told) {
|
|
649
|
+
told.said = true;
|
|
650
|
+
if (told.timer) { clearTimeout(told.timer); told.timer = null; }
|
|
651
|
+
}
|
|
652
|
+
say(told && settings().stopped ? { ...notice, body: `${notice.body} · ${stoppedCount(told.ids.size)}` } : notice);
|
|
653
|
+
}
|
|
474
654
|
return;
|
|
475
655
|
}
|
|
656
|
+
if (result.trigger === "login-expired") {
|
|
657
|
+
stuckSaid.delete(accountOfRef(result.from, accounts)?.key ?? String(result.from?.number ?? ""));
|
|
658
|
+
}
|
|
476
659
|
const at = now();
|
|
477
660
|
const s = settings();
|
|
478
661
|
const from = accountOfRef(result.from, accounts);
|
|
@@ -491,11 +674,19 @@ export function createAccountNotifier({
|
|
|
491
674
|
if (from?.key) carryFromPoll(result, from, at, !s.swap);
|
|
492
675
|
release([...held.values()]);
|
|
493
676
|
if (s.swap) {
|
|
494
|
-
|
|
677
|
+
const notice = swapNotice({
|
|
495
678
|
toName: to?.name ?? null, fromName: from?.name ?? null,
|
|
496
679
|
used: usedBefore(result, result.from?.number), threshold: result.threshold,
|
|
497
680
|
trigger: result.trigger ?? null, resetAt: result.resetAt ?? null, now: at,
|
|
498
|
-
})
|
|
681
|
+
});
|
|
682
|
+
// The incident this switch cured, said with it — and said, so its own
|
|
683
|
+
// notice does not follow.
|
|
684
|
+
const cured = inc && incident === inc && !inc.said ? inc : null;
|
|
685
|
+
if (cured) {
|
|
686
|
+
cured.said = true;
|
|
687
|
+
if (cured.timer) { clearTimeout(cured.timer); cured.timer = null; }
|
|
688
|
+
}
|
|
689
|
+
say(cured && s.stopped ? withStopped(notice, cured.ids.size) : notice);
|
|
499
690
|
}
|
|
500
691
|
await persist();
|
|
501
692
|
} catch (err) {
|
|
@@ -534,14 +725,20 @@ export function createAccountNotifier({
|
|
|
534
725
|
ready,
|
|
535
726
|
observe,
|
|
536
727
|
tick,
|
|
728
|
+
stopped,
|
|
537
729
|
/** The loop was switched on or off. Off, nothing will carry what is held. */
|
|
538
730
|
autoSwitch(on) {
|
|
539
731
|
autoOn = on === true;
|
|
540
|
-
if (!autoOn)
|
|
732
|
+
if (!autoOn) {
|
|
733
|
+
release([...held.values()]);
|
|
734
|
+
if (incident?.held && !incident.said) sayIncident();
|
|
735
|
+
}
|
|
541
736
|
},
|
|
542
|
-
/** For the suite: the records as they stand,
|
|
737
|
+
/** For the suite: the records as they stand, what is held, and the stop
|
|
738
|
+
* incident. */
|
|
543
739
|
records: () => records,
|
|
544
740
|
held: () => [...held.values()],
|
|
741
|
+
incident: () => incident,
|
|
545
742
|
settled: () => saving,
|
|
546
743
|
};
|
|
547
744
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Which account a command line means (#2183): `ccdeck switch 2`, `ccdeck switch
|
|
2
|
+
// work`, `ccdeck switch alice@`, `ccdeck switch --best`.
|
|
3
|
+
//
|
|
4
|
+
// Pure — roster rows in, a verdict out — because both halves are rules a person
|
|
5
|
+
// leans on without looking, and a rule nobody can run in a test is the one that
|
|
6
|
+
// drifts. The rows are the roster's own (claude-accounts.mjs rosterRow), read
|
|
7
|
+
// either from a running deck or from claude-swap's store in this process.
|
|
8
|
+
import { oneLine } from "./cli-lines.mjs";
|
|
9
|
+
// Auto-switch's own chooser (#2200) and the key it names accounts by. Both are
|
|
10
|
+
// pure and import nothing that starts anything.
|
|
11
|
+
import { chooseAccount, readingCurrent, usedOf } from "./account-choice.mjs";
|
|
12
|
+
import { accountKey } from "./lan-copies.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The account `query` names, as `{ account }`, or why there is none: `{ none }`
|
|
16
|
+
* for a name nothing matches, `{ ambiguous: [rows] }` for one several do.
|
|
17
|
+
*
|
|
18
|
+
* In the order a person means them. A whole number is a slot, and only a slot —
|
|
19
|
+
* `2` is never the second account in the list, because the list's order is the
|
|
20
|
+
* rotation's and the slot is what `cswap` and the panel both print. Then an
|
|
21
|
+
* exact email or alias, case-insensitive. Then a fragment of either, which is
|
|
22
|
+
* what `alice` or `@corp` is, accepted only when it narrows to one account: a
|
|
23
|
+
* switch that guessed between two would move the live login of every running
|
|
24
|
+
* session on a coin toss.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveAccount(accounts, query) {
|
|
27
|
+
const rows = Array.isArray(accounts) ? accounts : [];
|
|
28
|
+
const q = String(query ?? "").trim();
|
|
29
|
+
if (!q) return { none: true };
|
|
30
|
+
if (/^\d+$/.test(q)) {
|
|
31
|
+
const hit = rows.find(a => a.num === Number(q));
|
|
32
|
+
return hit ? { account: hit } : { none: true };
|
|
33
|
+
}
|
|
34
|
+
const lower = q.toLowerCase();
|
|
35
|
+
const exact = rows.filter(a => lc(a.email) === lower || lc(a.alias) === lower);
|
|
36
|
+
if (exact.length === 1) return { account: exact[0] };
|
|
37
|
+
if (exact.length > 1) return { ambiguous: exact };
|
|
38
|
+
const partial = rows.filter(a => lc(a.email).includes(lower) || lc(a.alias).includes(lower));
|
|
39
|
+
if (partial.length === 1) return { account: partial[0] };
|
|
40
|
+
if (partial.length > 1) return { ambiguous: partial };
|
|
41
|
+
return { none: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lc = (s) => (typeof s === "string" ? s.toLowerCase() : "");
|
|
45
|
+
|
|
46
|
+
/** The errors that mean the stored login itself was refused — the ones the
|
|
47
|
+
* panel offers "Sign in again" for (account-issue.ts errorText `fixable`). */
|
|
48
|
+
const SIGN_IN_ERRORS = new Set(["invalid_grant", "no_refresh_token", "http-401"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the accounts panel withholds its Switch button from this row — the
|
|
52
|
+
* server-side copy of account-issue.ts's `blocksSwitch`, which
|
|
53
|
+
* cli-switch-rules-2183.test.ts holds to it row by row.
|
|
54
|
+
*
|
|
55
|
+
* A login that is dead or was never stored: claude-swap refused its token, or
|
|
56
|
+
* stopped collecting it on a verdict that only a sign-in clears. Never a stale
|
|
57
|
+
* copy (#721): the login works, only claude-swap's copy is behind. And not a
|
|
58
|
+
* rate limit, a timeout or a network error — those clear by themselves, and the
|
|
59
|
+
* panel switches to such an account when asked.
|
|
60
|
+
*/
|
|
61
|
+
export function blocksSwitch(a) {
|
|
62
|
+
if (!a || a.staleCopy === true) return false;
|
|
63
|
+
if (a.stopped === true) return a.collector === "no_credentials" || a.collector === "relogin_required";
|
|
64
|
+
return typeof a.error === "string" && SIGN_IN_ERRORS.has(a.error);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Could the live login be moved here at all, when somebody names it?
|
|
69
|
+
*
|
|
70
|
+
* Exactly when the panel would offer the switch: not a disabled slot —
|
|
71
|
+
* claude-swap's own "keep out of rotation", which a person set on purpose —
|
|
72
|
+
* and not one whose stored login is known to be dead (blocksSwitch): switching
|
|
73
|
+
* to it signs every running session out.
|
|
74
|
+
*/
|
|
75
|
+
export function switchable(a) {
|
|
76
|
+
return Boolean(a) && a.disabled !== true && !blocksSwitch(a);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether `--best` may land on this row by room alone: switchable, and with no
|
|
80
|
+
* collector error at all. A reading that failed — a rate limit, a timeout — is
|
|
81
|
+
* no evidence of room, which is what `--best` promises; Auto-switch's own
|
|
82
|
+
* chooser skips any row with an error too (account-choice.mjs `landable`). A
|
|
83
|
+
* stale copy is fine, since its login works (see authTrouble). */
|
|
84
|
+
function roomCandidate(a) {
|
|
85
|
+
return switchable(a) && !(a.error && a.staleCopy !== true);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Where `--best` goes: `{ account, rule }`, `{ already: row, rule }` when the
|
|
90
|
+
* active account is it, or `{ none: true }` when nothing can take the login.
|
|
91
|
+
*
|
|
92
|
+
* WHAT AUTO-SWITCH WOULD PICK (#2200). `rules` is Auto-switch's own state as
|
|
93
|
+
* the deck holds it — `{ next, home, threshold, models, busy, quarantined,
|
|
94
|
+
* optional, now }`, see cswap-auto-choice.mjs bestRules — or null. With a home
|
|
95
|
+
* account or "soonest weekly reset" set, the answer is account-choice.mjs's
|
|
96
|
+
* chooseAccount's, asked the way a tick right after a person's request would
|
|
97
|
+
* be: nothing picked by hand to protect, the home asked for, no cooldown. Its
|
|
98
|
+
* `rule` is that pick's trigger, "home" or "weekly-reset". When it names no
|
|
99
|
+
* account and the account in use is under the threshold on a current reading,
|
|
100
|
+
* Auto-switch would keep it, and so does this (`rule: "rules"`, or "home" on
|
|
101
|
+
* the home account). Otherwise — no rule set, or the active account full or
|
|
102
|
+
* unread and no rule's account free — it is the engine's own choice, below.
|
|
103
|
+
*
|
|
104
|
+
* MOST ROOM, the engine's `best`. Room is the roster's own `headroom` — against
|
|
105
|
+
* the tightest lane, so an account at 10% of five hours and 97% of the week is
|
|
106
|
+
* not chosen over one at 50% of both. A reading nobody has taken counts as no
|
|
107
|
+
* room rather than as full: the point of `--best` is to land somewhere known to
|
|
108
|
+
* have space. A tie with the active account stays where it is — a switch that
|
|
109
|
+
* gains no room still moves every running session — and other ties go to a
|
|
110
|
+
* fresh reading over a stale one, then to the lower slot, so the answer is the
|
|
111
|
+
* same every time it is asked.
|
|
112
|
+
*/
|
|
113
|
+
export function bestAccount(accounts, rules = null) {
|
|
114
|
+
const all = Array.isArray(accounts) ? accounts : [];
|
|
115
|
+
const rows = all.filter(roomCandidate);
|
|
116
|
+
const ruled = rules ? byRules(all, rules) : null;
|
|
117
|
+
if (ruled) return ruled;
|
|
118
|
+
if (!rows.length) return { none: true };
|
|
119
|
+
const room = (a) => (typeof a.headroom === "number" ? a.headroom : -1);
|
|
120
|
+
const best = [...rows].sort((x, y) =>
|
|
121
|
+
room(y) - room(x)
|
|
122
|
+
|| Number(y.active === true) - Number(x.active === true)
|
|
123
|
+
|| Number(x.stale === true) - Number(y.stale === true)
|
|
124
|
+
|| x.num - y.num)[0];
|
|
125
|
+
return best.active ? { already: best, rule: "most-room" } : { account: best, rule: "most-room" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** chooseAccount's answer for `--best`, as bestAccount describes it, or null
|
|
129
|
+
* for "the engine's own choice". */
|
|
130
|
+
function byRules(rows, { next = "room", home = null, threshold, models = [], busy, quarantined, optional = true, now = Date.now() } = {}) {
|
|
131
|
+
if (next !== "weekly" && !home) return null;
|
|
132
|
+
if (!Number.isFinite(threshold)) return null;
|
|
133
|
+
const keyed = rows.map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
|
|
134
|
+
const original = (k) => rows[keyed.indexOf(k)];
|
|
135
|
+
const pick = chooseAccount({
|
|
136
|
+
accounts: keyed,
|
|
137
|
+
choice: { next, home, manual: false, goHome: true },
|
|
138
|
+
threshold,
|
|
139
|
+
models,
|
|
140
|
+
busy: busy instanceof Set ? busy : new Set(),
|
|
141
|
+
quarantined: quarantined instanceof Set ? quarantined : new Set(),
|
|
142
|
+
sinceMove: Infinity,
|
|
143
|
+
optional: optional !== false,
|
|
144
|
+
now,
|
|
145
|
+
});
|
|
146
|
+
if (pick) return { account: original(pick.to), rule: pick.trigger };
|
|
147
|
+
const active = keyed.find(a => a.active);
|
|
148
|
+
if (!active || !readingCurrent(active, models, now)) return null;
|
|
149
|
+
const used = usedOf(active, models);
|
|
150
|
+
if (used == null || used >= threshold) return null;
|
|
151
|
+
return { already: original(active), rule: home && active.key === home ? "home" : "rules" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** How a row is named back to its reader: `#2 alice@corp.com (work)`. On one
|
|
155
|
+
* line and without control bytes, since an alias is whatever was typed. */
|
|
156
|
+
export function accountName(a) {
|
|
157
|
+
if (!a) return "";
|
|
158
|
+
const who = a.email ?? "(no email)";
|
|
159
|
+
return oneLine(`#${a.num} ${who}${a.alias ? ` (${a.alias})` : ""}`);
|
|
160
|
+
}
|