agent-dag 3.19.0 β 3.21.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 +176 -6
- package/bin/agent-dag.js +125 -4
- package/bin/deck.js +532 -4
- package/dist/web/assets/index-BYMVIG6f.css +1 -0
- package/dist/web/assets/index-G0ntHhBn.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/activity.mjs +52 -0
- package/src/server/args.mjs +27 -0
- package/src/server/auto-update.mjs +79 -0
- package/src/server/claude-accounts.mjs +205 -6
- package/src/server/cswap-admin.mjs +104 -11
- package/src/server/deck-home.mjs +243 -0
- package/src/server/deck-prefs.mjs +88 -8
- package/src/server/deck-probe.mjs +111 -0
- package/src/server/detach.mjs +244 -0
- package/src/server/global-install.mjs +67 -0
- package/src/server/index.mjs +275 -88
- package/src/server/installer.mjs +33 -8
- package/src/server/lan-about.mjs +119 -0
- package/src/server/lan-engine.mjs +142 -9
- package/src/server/lan-socket.mjs +94 -11
- package/src/server/lan-sync.mjs +5 -1
- package/src/server/login-service.mjs +473 -0
- package/src/server/presence.mjs +40 -0
- package/src/server/running-deck.mjs +234 -0
- package/src/server/self-update.mjs +5 -2
- package/src/server/stop-deck.mjs +171 -0
- package/src/server/supervisor.mjs +90 -0
- package/src/server/term.mjs +35 -2
- package/dist/web/assets/index-CE-8EKi6.css +0 -1
- package/dist/web/assets/index-DTbVC1d9.js +0 -266
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-G0ntHhBn.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BYMVIG6f.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.0",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents β watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -31,6 +31,18 @@
|
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
32
|
"all."
|
|
33
33
|
],
|
|
34
|
+
"3.21.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "πΌοΈ The deck explains itself in pictures",
|
|
37
|
+
"body": "π¬ First run opens an eight-picture tour instead of the changelog β who is waiting on you, the tree, a node's inside, cost and quota, this machine, accounts, local network, how to start. `Take the tour` on an empty canvas brings it back.\n\nπ‘ **Local network** has a guide: a card while it is off, `How it works` under an empty list, four pictures ending on `Turn it on`. A machine's row opens its own dialog β address, version and system, the logins it offers, and a name of your own for it. Setup ends on Done.\n\nπ The deck updates itself when nobody is looking: no tab focused, no agent mid-turn, thirty seconds quiet. The banner's `auto when idle` switch governs it.\n\nπ An account whose numbers were paused resumes on its own, and the panel is titled **Claude accounts** β a Codex login is not in it."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"3.20.0": [
|
|
41
|
+
{
|
|
42
|
+
"title": "π’ The deck keeps running after you close the terminal",
|
|
43
|
+
"body": "`ccdeck` prints the same boot report and gives you the prompt back. The deck stays up, and starts again when you log in.\n\nπ **`ccdeck --stop` is the off switch now** β Ctrl+C only cancels a start that is still printing. `--status` says what is running, `--logs` shows what the deck wrote while you were away, and `--foreground` holds the terminal the way every version before this one did.\n\nπ Typing `ccdeck` beside a deck that is already up opens **that deck's tab** instead of building a second one. `ccdeck --stop --all` clears any older deck still running from before this version. An `npx` run cannot start at login β `npx ccdeck --install` puts it on your PATH and sets that up.\n\nπ An account that will not collect now says which of three things is wrong: **no stored login**, a **login that expired**, or a **keychain the deck cannot open** β with the fix beside it. One with no stored login is filled over the local network by a paired deck that has it.\n\nπ‘ And decks find each other again on a machine with a VPN up: the beacon was going to an address that never left the machine, so this deck could hear colleagues and none of them could hear it."
|
|
44
|
+
}
|
|
45
|
+
],
|
|
34
46
|
"3.19.0": [
|
|
35
47
|
{
|
|
36
48
|
"title": "π’ Local network: fixes and improvements",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Whether any agent is mid-turn, from the hook stream alone.
|
|
2
|
+
//
|
|
3
|
+
// For the one decision the server takes on its own that must not land in the
|
|
4
|
+
// middle of a turn: restarting into a new version while nobody is looking
|
|
5
|
+
// (auto-update.mjs). A restart mid-turn drops the hook events fired during the
|
|
6
|
+
// gap, and the canvas shows those tools in flight until the stale sweeper
|
|
7
|
+
// reaps them β restart.ts states the rule for the page, which decides it from
|
|
8
|
+
// its reducer's `active` state.
|
|
9
|
+
//
|
|
10
|
+
// With no page open there is no reducer, so this keeps the smallest version of
|
|
11
|
+
// it: a session is open from the first event of a turn until the turn ends
|
|
12
|
+
// (`Stop`, `SessionEnd`). SessionStart and Notification move nothing β a CLI
|
|
13
|
+
// sitting at its prompt is not busy, and the idle reminder arrives after the
|
|
14
|
+
// turn it is about. An open session whose last event is older than
|
|
15
|
+
// OPEN_CAP_MS counts as gone rather than busy: a CLI that crashed never sends
|
|
16
|
+
// its Stop, and waiting on one forever would mean never updating.
|
|
17
|
+
export const OPEN_CAP_MS = 30 * 60_000;
|
|
18
|
+
|
|
19
|
+
const OPENS = new Set([
|
|
20
|
+
"UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop",
|
|
21
|
+
]);
|
|
22
|
+
const CLOSES = new Set(["Stop", "SessionEnd"]);
|
|
23
|
+
|
|
24
|
+
export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
|
|
25
|
+
/** session id β when its turn last produced an event */
|
|
26
|
+
const open = new Map();
|
|
27
|
+
let lastAt = 0;
|
|
28
|
+
return {
|
|
29
|
+
/** One hook payload, as pushEvent receives it. Anything else is ignored. */
|
|
30
|
+
note(raw, at) {
|
|
31
|
+
const name = raw?.hook_event_name;
|
|
32
|
+
if (!OPENS.has(name) && !CLOSES.has(name)) return;
|
|
33
|
+
if (at > lastAt) lastAt = at;
|
|
34
|
+
const id = raw?.session_id;
|
|
35
|
+
if (typeof id !== "string" || !id) return;
|
|
36
|
+
if (CLOSES.has(name)) open.delete(id);
|
|
37
|
+
else open.set(id, at);
|
|
38
|
+
},
|
|
39
|
+
/** Whether any turn is still running. */
|
|
40
|
+
busy(now) {
|
|
41
|
+
for (const [id, at] of open) {
|
|
42
|
+
if (Math.abs(now - at) > openCapMs) open.delete(id);
|
|
43
|
+
else return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
},
|
|
47
|
+
/** How long since the last turn event of any session β Infinity before the first. */
|
|
48
|
+
quietMs(now) {
|
|
49
|
+
return lastAt ? Math.max(0, now - lastAt) : Infinity;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/server/args.mjs
CHANGED
|
@@ -110,6 +110,25 @@ export function isPortValue(raw) {
|
|
|
110
110
|
* C:\Users\John Smith\proj` reaches the parser as two arguments, and the second
|
|
111
111
|
* one used to be dropped in silence (see launchNpx in bin/agent-dag.js).
|
|
112
112
|
*/
|
|
113
|
+
/**
|
|
114
|
+
* The flags that do a thing and exit, rather than starting a deck.
|
|
115
|
+
*
|
|
116
|
+
* bin/agent-dag.js reads this before it decides whether to detach, and that is
|
|
117
|
+
* the whole reason it exists as a list rather than as a condition written out
|
|
118
|
+
* at the call site: a one-shot that detached would print its answer into a log
|
|
119
|
+
* file and hand the terminal back empty. `ccdeck --version` detaching itself is
|
|
120
|
+
* the shape of the bug this prevents.
|
|
121
|
+
*/
|
|
122
|
+
export const ONE_SHOT = Object.freeze([
|
|
123
|
+
"help", "version", "uninstall", "stop", "status", "logs",
|
|
124
|
+
"install", "installService", "uninstallService",
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/** Is this a command line that answers and leaves? */
|
|
128
|
+
export function isOneShot(flags = {}) {
|
|
129
|
+
return ONE_SHOT.some((k) => flags[k] === true);
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
export function parseArgs(args) {
|
|
114
133
|
const out = { unknown: [], incomplete: [] };
|
|
115
134
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -140,6 +159,14 @@ export function parseArgs(args) {
|
|
|
140
159
|
else if (a === "-v" || a === "--version") out.version = true;
|
|
141
160
|
else if (a === "-p" || a === "--port") set("port", "a port number");
|
|
142
161
|
else if (a === "--no-open") out.noOpen = true;
|
|
162
|
+
else if (a === "--new") out.new = true;
|
|
163
|
+
else if (a === "--foreground") out.foreground = true;
|
|
164
|
+
else if (a === "--stop") out.stop = true;
|
|
165
|
+
else if (a === "--status") out.status = true;
|
|
166
|
+
else if (a === "--logs") out.logs = true;
|
|
167
|
+
else if (a === "--install") out.install = true;
|
|
168
|
+
else if (a === "--install-service") out.installService = true;
|
|
169
|
+
else if (a === "--uninstall-service") out.uninstallService = true;
|
|
143
170
|
else if (a === "--uninstall") out.uninstall = true;
|
|
144
171
|
else if (a === "--workspace") set("workspace", "a path");
|
|
145
172
|
else if (a === "--scope") out.scope = true;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Updating the deck while nobody is looking at it.
|
|
2
|
+
//
|
|
3
|
+
// The update path had one automatic step, and it needed a page: the banner's
|
|
4
|
+
// `auto when idle` restarted the deck once an install had landed, and only in a
|
|
5
|
+
// tab somebody was looking at. An install itself never happened unless a person
|
|
6
|
+
// pressed `Update now`, and a deck with no tab open stayed on its old code for
|
|
7
|
+
// good. Since 3.20.0 the deck outlives its terminal and runs for days with no
|
|
8
|
+
// tab at all, which made that the common case.
|
|
9
|
+
//
|
|
10
|
+
// So the server does it, at the one moment it cannot interrupt anybody: no tab
|
|
11
|
+
// has focus (presence.mjs), no agent is mid-turn, and no turn has produced an
|
|
12
|
+
// event for AWAY_QUIET_MS (activity.mjs). The same switch governs it β the
|
|
13
|
+
// banner's `auto when idle`, kept in prefs.json as `autoUpdate` now so the
|
|
14
|
+
// server can read it with no page open. A person looking at the deck still gets
|
|
15
|
+
// the banner and its buttons; this is only for when they are not.
|
|
16
|
+
//
|
|
17
|
+
// Every effect goes through the code a press uses β startUpgrade, and the
|
|
18
|
+
// launcher's restart β so an unattended update cannot do anything a person
|
|
19
|
+
// could not have done from the banner. Pure rules here; index.mjs owns the
|
|
20
|
+
// timer and the effects.
|
|
21
|
+
|
|
22
|
+
/** Quiet before the deck may act: the page's IDLE_BEFORE_RESTART_MS, so the
|
|
23
|
+
* two paths agree on what idle means. */
|
|
24
|
+
export const AWAY_QUIET_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
/** How long a deck that has just started waits before acting at all. A
|
|
27
|
+
* relaunch that came back on the version it left would otherwise try again a
|
|
28
|
+
* minute later, and again after that; this caps any such loop at one attempt
|
|
29
|
+
* per boot grace, under the launcher's own two-strike rule for npx
|
|
30
|
+
* (supervisor.mjs, upgradeAttempt). */
|
|
31
|
+
export const AWAY_BOOT_GRACE_MS = 5 * 60_000;
|
|
32
|
+
|
|
33
|
+
/** How long the same attempt is left alone after it was made: an install that
|
|
34
|
+
* failed, a fetch the launcher refused, a restart it could not run. A newer
|
|
35
|
+
* version is a different attempt and is not held back by this. */
|
|
36
|
+
export const AWAY_RETRY_MS = 30 * 60_000;
|
|
37
|
+
|
|
38
|
+
/** How often the server asks. The gate below is answered from memory; only
|
|
39
|
+
* when it passes does the tick read the version report, which touches disk. */
|
|
40
|
+
export const AWAY_TICK_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
/** How long a report that found nothing newer is taken as the answer. The npm
|
|
43
|
+
* lookup behind it is hourly anyway, and the report is not free: on Windows
|
|
44
|
+
* upgradeBlock proves the npm prefix writable by creating a file in it, and a
|
|
45
|
+
* deck left alone overnight would otherwise do that once a minute. */
|
|
46
|
+
export const AWAY_RECHECK_MS = 5 * 60_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the deck may consider updating itself right now. Every input is
|
|
50
|
+
* already in memory, so this runs every tick at no cost.
|
|
51
|
+
*/
|
|
52
|
+
export function awayGate({
|
|
53
|
+
enabled, supervised, restarting, sinceBootMs, looking, busy, quietMs,
|
|
54
|
+
graceMs = AWAY_BOOT_GRACE_MS, quietNeedMs = AWAY_QUIET_MS,
|
|
55
|
+
}) {
|
|
56
|
+
return enabled === true && supervised === true && !restarting
|
|
57
|
+
&& sinceBootMs >= graceMs && !looking && !busy && quietMs >= quietNeedMs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What to do about the version report, once the gate has passed.
|
|
62
|
+
*
|
|
63
|
+
* restart the newer code is already on disk (an install landed, or a
|
|
64
|
+
* checkout was pulled) β the free half, as pickNotice says
|
|
65
|
+
* install `npm i -g` over a global install; the next tick restarts into it
|
|
66
|
+
* npx the launcher fetches the new version and hands it the port
|
|
67
|
+
* null nothing newer, an install already running, the same attempt
|
|
68
|
+
* made too recently, or a copy that only a person can update
|
|
69
|
+
*/
|
|
70
|
+
export function awayUpdateStep({ notice, mode, installing, lastTry, now, retryMs = AWAY_RETRY_MS }) {
|
|
71
|
+
if (!notice || installing) return { act: null };
|
|
72
|
+
const target = `${notice.kind}:${notice.to}`;
|
|
73
|
+
// A clock that moved backwards counts as time elapsed, as everywhere else in
|
|
74
|
+
// the update path: a deck must not wait out a window it cannot measure.
|
|
75
|
+
if (lastTry?.target === target && now >= lastTry.at && now - lastTry.at < retryMs) return { act: null, target };
|
|
76
|
+
if (notice.kind === "restart") return { act: "restart", target };
|
|
77
|
+
if (notice.kind === "upgrade" && (mode === "install" || mode === "npx")) return { act: mode, target };
|
|
78
|
+
return { act: null, target };
|
|
79
|
+
}
|
|
@@ -151,6 +151,27 @@ function heldReading(now) {
|
|
|
151
151
|
// without a marker would misrepresent them (its own trust ceiling is 3600s).
|
|
152
152
|
const STALE_AFTER_MS = 15 * 60_000;
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* How long a collector may produce nothing before that is a fault rather than a
|
|
156
|
+
* cadence.
|
|
157
|
+
*
|
|
158
|
+
* TWELVE HOURS, and the number is chosen from what the failure actually looks
|
|
159
|
+
* like rather than from taste. Measured on the machine that reported this: three
|
|
160
|
+
* accounts last collected 21 hours, 40 hours and 28 days ago, every one of them
|
|
161
|
+
* with `consecutiveFailures: 0` β because the thing stopping them was
|
|
162
|
+
* `keychain_unavailable`, which is claude-swap failing to OPEN the credential
|
|
163
|
+
* rather than having it rejected, and which never touches that counter.
|
|
164
|
+
*
|
|
165
|
+
* Long enough that a laptop closed overnight does not trip it. Short enough that
|
|
166
|
+
* an account nobody can read stops being advertised to the group as one they
|
|
167
|
+
* can. And the cost of being wrong is deliberately lopsided: a false "not
|
|
168
|
+
* collecting" asks a peer for a blob that `cswap import` then declines, because
|
|
169
|
+
* a plain import skips an account that is present and healthy β while the false
|
|
170
|
+
* negative this replaces is an account that is never repaired by anything, ever,
|
|
171
|
+
* and is published to every paired deck as good.
|
|
172
|
+
*/
|
|
173
|
+
const COLLECTION_STOPPED_AFTER_MS = 12 * 60 * 60_000;
|
|
174
|
+
|
|
154
175
|
// Nudging the collector.
|
|
155
176
|
//
|
|
156
177
|
// Two throttles, because the cost of asking is not the cost of fetching. When
|
|
@@ -289,6 +310,94 @@ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
|
|
|
289
310
|
* Either way claude-swap decides whether a network call actually happens, and
|
|
290
311
|
* this is throttled on top of that.
|
|
291
312
|
*/
|
|
313
|
+
/**
|
|
314
|
+
* What claude-swap says about each slot, in its own words.
|
|
315
|
+
*
|
|
316
|
+
* `usage.json` records numbers and a failure COUNTER; `cswap list --json`
|
|
317
|
+
* records a per-slot VERDICT, and the two answer different questions. Measured
|
|
318
|
+
* on the machine this was written for, at one instant, for the same account:
|
|
319
|
+
*
|
|
320
|
+
* usage.json -> consecutiveFailures: 0, lastError: null
|
|
321
|
+
* cswap list -> usageStatus: "no_credentials"
|
|
322
|
+
*
|
|
323
|
+
* Three verdicts, three different things for a person to do β `no_credentials`
|
|
324
|
+
* is an account to receive or re-add, `relogin_required` is one to sign into,
|
|
325
|
+
* `keychain_unavailable` is not about the account at all but about the process
|
|
326
|
+
* asking. The counter can tell none of them apart, and for two of the three it
|
|
327
|
+
* reads zero.
|
|
328
|
+
*
|
|
329
|
+
* WHERE IT COMES FROM COSTS NOTHING EXTRA. nudgeCollector already spawns
|
|
330
|
+
* `cswap list` when a collection is due, and threw the output away. It now asks
|
|
331
|
+
* for `--json` and keeps the verdicts. Still not awaited by anyone β the nudge
|
|
332
|
+
* stays synchronous for its callers β and still one child at a time.
|
|
333
|
+
*/
|
|
334
|
+
let _verdicts = { at: 0, byNum: {} };
|
|
335
|
+
/** Stale after this, because a verdict that outlives its cause is worse than no
|
|
336
|
+
* verdict: "no credentials" under an account somebody has since signed into is
|
|
337
|
+
* a sentence that sends them to fix what is already fixed. */
|
|
338
|
+
const VERDICT_TTL_MS = 10 * 60_000;
|
|
339
|
+
/** One `cswap list --json` at a time. A collection can take a while on a cold
|
|
340
|
+
* network, and a nudge landing inside one must not start a second. */
|
|
341
|
+
let _verdictInFlight = false;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
|
|
345
|
+
*
|
|
346
|
+
* The cached verdicts are ten minutes old at worst, which is right for a label
|
|
347
|
+
* and wrong for a decision that writes a credential: somebody who signed in two
|
|
348
|
+
* minutes ago still reads as `no_credentials` there, and acting on that would
|
|
349
|
+
* replace the login they just created with a peer's.
|
|
350
|
+
*
|
|
351
|
+
* Matched on IDENTITY rather than on the slot number, because a number is a
|
|
352
|
+
* position in one machine's store and the caller is holding an account.
|
|
353
|
+
*
|
|
354
|
+
* Returns null when the question cannot be answered β no claude-swap, a refusal,
|
|
355
|
+
* an account it does not know. Null is not `no_credentials`, and the one caller
|
|
356
|
+
* treats it as "do not act".
|
|
357
|
+
*/
|
|
358
|
+
export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
|
|
359
|
+
const want = String(email ?? "").trim().toLowerCase();
|
|
360
|
+
if (!want) return null;
|
|
361
|
+
try {
|
|
362
|
+
const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
|
|
363
|
+
if (!out?.ok) return null;
|
|
364
|
+
const d = JSON.parse(out.stdout);
|
|
365
|
+
// The same freshly-read answer feeds the cache the panel draws from, since
|
|
366
|
+
// it cost a subprocess either way.
|
|
367
|
+
const byNum = readVerdicts(out.stdout);
|
|
368
|
+
if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
|
|
369
|
+
for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
|
|
370
|
+
if (String(a?.email ?? "").trim().toLowerCase() !== want) continue;
|
|
371
|
+
if ((a?.organizationUuid ?? "") !== (org ?? "")) continue;
|
|
372
|
+
return typeof a?.usageStatus === "string" ? a.usageStatus : null;
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
} catch { return null; }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** claude-swap's verdict for a slot, or null when there is none fresh enough. */
|
|
379
|
+
function verdictFor(num, now) {
|
|
380
|
+
if (now - _verdicts.at > VERDICT_TTL_MS) return null;
|
|
381
|
+
const v = _verdicts.byNum[String(num)];
|
|
382
|
+
return typeof v === "string" && v !== "" ? v : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Read the verdicts out of a `cswap list --json` payload. Tolerant by
|
|
386
|
+
* construction: this is another tool's output, and a shape we do not recognise
|
|
387
|
+
* means no verdicts rather than a thrown boot. */
|
|
388
|
+
export function readVerdicts(stdout) {
|
|
389
|
+
try {
|
|
390
|
+
const d = JSON.parse(stdout);
|
|
391
|
+
const out = {};
|
|
392
|
+
for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
|
|
393
|
+
if (typeof a?.number === "number" && typeof a?.usageStatus === "string") {
|
|
394
|
+
out[String(a.number)] = a.usageStatus;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
} catch { return {}; }
|
|
399
|
+
}
|
|
400
|
+
|
|
292
401
|
function nudgeCollector(rows, slots, now, activeNum) {
|
|
293
402
|
// Did the last ask accomplish anything? Cheap proxy: the newest collection
|
|
294
403
|
// timestamp in the store.
|
|
@@ -307,12 +416,35 @@ function nudgeCollector(rows, slots, now, activeNum) {
|
|
|
307
416
|
if (!due && !freshen) return;
|
|
308
417
|
|
|
309
418
|
_lastNudge = now;
|
|
310
|
-
|
|
419
|
+
// The due path asks for JSON and KEEPS it β see _verdicts. The dry-run path
|
|
420
|
+
// stays detached: it is the engine's own collect pass, its JSON is a different
|
|
421
|
+
// shape, and nothing here reads it.
|
|
422
|
+
if (!due) {
|
|
423
|
+
cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (_verdictInFlight) return;
|
|
427
|
+
_verdictInFlight = true;
|
|
311
428
|
// Fire-and-forget: this function is deliberately synchronous so callers never
|
|
312
|
-
// wait on it, and resolving the binary is the only async part.
|
|
313
|
-
|
|
429
|
+
// wait on it, and resolving the binary is the only async part. `run` rather
|
|
430
|
+
// than `runDetached` only so the output can be read; the caller is no more
|
|
431
|
+
// aware of it than before.
|
|
432
|
+
cswapBin()
|
|
433
|
+
.then(bin => run(bin, ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS }))
|
|
434
|
+
.then(out => {
|
|
435
|
+
const byNum = out?.ok ? readVerdicts(out.stdout) : {};
|
|
436
|
+
// Replaced whole rather than merged. A slot that has gone away must not
|
|
437
|
+
// keep the verdict it had when it was last seen.
|
|
438
|
+
if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
|
|
439
|
+
})
|
|
440
|
+
.catch(() => {})
|
|
441
|
+
.finally(() => { _verdictInFlight = false; });
|
|
314
442
|
}
|
|
315
443
|
|
|
444
|
+
/** Long enough for a cold collection over a slow network, short enough that a
|
|
445
|
+
* wedged claude-swap does not hold a child for the rest of the day. */
|
|
446
|
+
const VERDICT_TIMEOUT_MS = 90_000;
|
|
447
|
+
|
|
316
448
|
async function readJson(path) {
|
|
317
449
|
try {
|
|
318
450
|
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -372,6 +504,34 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
|
|
|
372
504
|
return mine;
|
|
373
505
|
}
|
|
374
506
|
|
|
507
|
+
// ββ the repair nobody should have to press βββββββββββββββββββββββββββββββββββ
|
|
508
|
+
//
|
|
509
|
+
// A `stale-copy` row β claude-swap's stored copy of the signed-in account's
|
|
510
|
+
// login was rejected, while the login itself works β has exactly one repair,
|
|
511
|
+
// and it asks the user nothing: re-capture the copy from the login they already
|
|
512
|
+
// have (cswap-admin's recaptureActive). It was a button, `resume`, which asked a
|
|
513
|
+
// person to confirm the only answer there is. So the read that finds the state
|
|
514
|
+
// starts the repair, and the row carries how it is going instead of a button.
|
|
515
|
+
//
|
|
516
|
+
// HANDED IN, NOT IMPORTED. The repair writes a credential into claude-swap's
|
|
517
|
+
// store, and this module is read by dozens of tests against fixture stores. The
|
|
518
|
+
// server hands it in when it starts listening (index.mjs) and nothing else does,
|
|
519
|
+
// so no test run can reach `cswap add` through a read.
|
|
520
|
+
let _repairStaleCopy = null;
|
|
521
|
+
|
|
522
|
+
/** Called with `{ num, email, now }` for a `stale-copy` row, and returns that
|
|
523
|
+
* row's `repair`. Null unregisters. */
|
|
524
|
+
export function repairStaleCopyWith(fn) {
|
|
525
|
+
_repairStaleCopy = typeof fn === "function" ? fn : null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The registered repair's state for one row. A repair that throws costs the
|
|
529
|
+
* row its state, never the read. */
|
|
530
|
+
function repairFor(num, email, now) {
|
|
531
|
+
if (!_repairStaleCopy) return null;
|
|
532
|
+
try { return _repairStaleCopy({ num, email, now }) ?? null; } catch { return null; }
|
|
533
|
+
}
|
|
534
|
+
|
|
375
535
|
/**
|
|
376
536
|
* The read itself, split out from the admission control above it so the guard is
|
|
377
537
|
* readable as the four lines it is.
|
|
@@ -449,7 +609,9 @@ async function readRoster(now, gen) {
|
|
|
449
609
|
|
|
450
610
|
const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
|
|
451
611
|
const isActive = String(seq.activeAccountNumber) === num;
|
|
452
|
-
const trouble = authTrouble(row, {
|
|
612
|
+
const trouble = authTrouble(row, {
|
|
613
|
+
matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
|
|
614
|
+
});
|
|
453
615
|
|
|
454
616
|
const lanes = [
|
|
455
617
|
lane("five_hour", "5h", good?.five_hour),
|
|
@@ -502,6 +664,21 @@ async function readRoster(now, gen) {
|
|
|
502
664
|
// in as it anyway. The panel says so quietly instead of offering to log
|
|
503
665
|
// them in again.
|
|
504
666
|
staleCopy: trouble?.kind === "stale-copy",
|
|
667
|
+
// How the deck's own repair of that is going β `{ state: "running" }` or
|
|
668
|
+
// `{ state: "failed", reason, retryAt }` β and null on every other row.
|
|
669
|
+
// Asking is what starts it; see repairStaleCopyWith.
|
|
670
|
+
repair: trouble?.kind === "stale-copy" ? repairFor(Number(num), acct.email ?? null, now) : null,
|
|
671
|
+
// Nothing has been collected for this account in half a day, and nothing
|
|
672
|
+
// says why. Its own word because the two existing ones would both be
|
|
673
|
+
// wrong: `error` claims a rejection that was never reported, and
|
|
674
|
+
// `staleCopy` promises the user is signed in as it, which is only
|
|
675
|
+
// knowable for the active account.
|
|
676
|
+
stopped: trouble?.kind === "stopped",
|
|
677
|
+
// claude-swap's own verdict for this slot, when there is a fresh one:
|
|
678
|
+
// "no_credentials", "relogin_required", "keychain_unavailable", β¦ It is
|
|
679
|
+
// what turns "not collecting" into a sentence with a next step in it, and
|
|
680
|
+
// it is null on every machine where the collector has not been asked yet.
|
|
681
|
+
collector: verdictFor(num, now),
|
|
505
682
|
});
|
|
506
683
|
}
|
|
507
684
|
|
|
@@ -541,8 +718,24 @@ async function readRoster(now, gen) {
|
|
|
541
718
|
* evidence of anything, so the stored verdict stands: refusing to show a real
|
|
542
719
|
* expiry because a subprocess failed is the opposite mistake.
|
|
543
720
|
*/
|
|
544
|
-
export function authTrouble(row, {
|
|
545
|
-
|
|
721
|
+
export function authTrouble(row, {
|
|
722
|
+
matches, isActive, identity, email,
|
|
723
|
+
fetchedAt = null, now = Date.now(), stoppedAfterMs = COLLECTION_STOPPED_AFTER_MS,
|
|
724
|
+
} = {}) {
|
|
725
|
+
if (!matches) return null;
|
|
726
|
+
const failing = (row?.consecutiveFailures ?? 0) > 0;
|
|
727
|
+
// A COLLECTOR THAT HAS PRODUCED NOTHING IN HALF A DAY IS FAILING, whatever
|
|
728
|
+
// its counter says β see COLLECTION_STOPPED_AFTER_MS. `consecutiveFailures`
|
|
729
|
+
// counts rejections, and the failure found on the reporting machine was not a
|
|
730
|
+
// rejection: `cswap list` answered `usageStatus: keychain_unavailable` for
|
|
731
|
+
// three accounts whose counters all read zero.
|
|
732
|
+
//
|
|
733
|
+
// `fetchedAt == null` is deliberately NOT this. That is an account nobody has
|
|
734
|
+
// ever collected β usually one added a minute ago β and the panel already has
|
|
735
|
+
// a word for it. Calling a new account broken is a worse first impression than
|
|
736
|
+
// saying nothing.
|
|
737
|
+
const stopped = !failing && fetchedAt != null && now - fetchedAt > stoppedAfterMs;
|
|
738
|
+
if (!failing && !stopped) return null;
|
|
546
739
|
|
|
547
740
|
const signedInHere = isActive
|
|
548
741
|
&& identity
|
|
@@ -555,6 +748,12 @@ export function authTrouble(row, { matches, isActive, identity, email } = {}) {
|
|
|
555
748
|
// fix under a red badge, and it must not offer to sign them in again.
|
|
556
749
|
return { kind: "stale-copy", error: null };
|
|
557
750
|
}
|
|
751
|
+
// A SILENCE, NOT A DIAGNOSIS. All that is known is that nothing has been
|
|
752
|
+
// collected for half a day; the reason lives in claude-swap and may be a dead
|
|
753
|
+
// login, a keychain it cannot open, or a machine that was off. Putting
|
|
754
|
+
// `invalid_grant` on it would be inventing evidence, and the panel would then
|
|
755
|
+
// offer "sign in again" for a problem that may not be a sign-in at all.
|
|
756
|
+
if (stopped) return { kind: "stopped", error: null };
|
|
558
757
|
return { kind: "auth", error: row.lastError ?? "error" };
|
|
559
758
|
}
|
|
560
759
|
|
|
@@ -987,6 +987,23 @@ export async function shareAccounts(nums) {
|
|
|
987
987
|
}
|
|
988
988
|
|
|
989
989
|
|
|
990
|
+
/**
|
|
991
|
+
* Did anything actually arrive in the store?
|
|
992
|
+
*
|
|
993
|
+
* THREE STATES MEAN YES AND THEY ARE NOT INTERCHANGEABLE. `imported` is a slot
|
|
994
|
+
* that did not exist; `healed` and `updated` are one that did and was rewritten,
|
|
995
|
+
* which is what a forced import produces and what `added` β counting `imported`
|
|
996
|
+
* alone β reports as nothing having happened. A caller that asked for a replace
|
|
997
|
+
* and read `added` would conclude its own success had failed.
|
|
998
|
+
*
|
|
999
|
+
* `present` is the decline: claude-swap kept what it had. `failed` is the
|
|
1000
|
+
* refusal. Neither is an arrival.
|
|
1001
|
+
*/
|
|
1002
|
+
export function landed(results) {
|
|
1003
|
+
return (Array.isArray(results) ? results : [])
|
|
1004
|
+
.some(r => r?.state === "imported" || r?.state === "healed" || r?.state === "updated");
|
|
1005
|
+
}
|
|
1006
|
+
|
|
990
1007
|
/**
|
|
991
1008
|
* The identities a bundle carries, or `[]` when it cannot be read.
|
|
992
1009
|
*
|
|
@@ -1199,26 +1216,39 @@ export function removePromptMatches(line, num) {
|
|
|
1199
1216
|
* refresh token dies it quarantines the row: no further collection is
|
|
1200
1217
|
* attempted, so the numbers freeze and every Refresh re-reads a store that
|
|
1201
1218
|
* cannot change. #721 fixed the sentence the panel said about that state; this
|
|
1202
|
-
* is the
|
|
1219
|
+
* is what ends it β started by the deck itself (autoRecapture, below) the
|
|
1220
|
+
* moment a read finds the state, and still reachable from the admin route.
|
|
1203
1221
|
*
|
|
1204
1222
|
* `cswap add` on an account already in the store is an idempotent credential
|
|
1205
1223
|
* refresh β registerSignedIn above says so in its own words: "No new slot means
|
|
1206
1224
|
* the account was already managed and cswap refreshed its credentials in
|
|
1207
|
-
* place." It captures whatever is signed in RIGHT NOW,
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
* account does not change.
|
|
1225
|
+
* place." It captures whatever is signed in RIGHT NOW, with the working
|
|
1226
|
+
* credentials the user already has. Nobody is signed in or out, no browser
|
|
1227
|
+
* opens, and the active account does not change.
|
|
1211
1228
|
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1229
|
+
* WHICH IS WHY IT ASKS WHO THAT IS FIRST. "Whatever is signed in" is the active
|
|
1230
|
+
* slot's account only while the two agree. Somebody who signed in as another
|
|
1231
|
+
* account in a terminal has made it a different one, and `cswap add` would then
|
|
1232
|
+
* refresh β or add β that account instead. Behind a press that was a narrow
|
|
1233
|
+
* race; with the deck starting it on its own it is the one thing that must be
|
|
1234
|
+
* ruled out. So the capture runs only when the CLI's account is the active
|
|
1235
|
+
* slot's, and, when the caller names an account, that one too.
|
|
1214
1236
|
*/
|
|
1215
|
-
export async function recaptureActive() {
|
|
1237
|
+
export async function recaptureActive({ expect = null } = {}) {
|
|
1216
1238
|
return withStoreLock(async () => {
|
|
1217
|
-
// Who is
|
|
1218
|
-
// captures the live credentials and this is the one check that the thing it
|
|
1219
|
-
// captured is the thing the user meant.
|
|
1239
|
+
// Who is signed in right now: `cswap add` captures THAT, whoever it is.
|
|
1220
1240
|
const before = await currentIdentity();
|
|
1221
1241
|
if (!before?.email) return { ok: false, reason: "not_signed_in" };
|
|
1242
|
+
const who = before.email.toLowerCase();
|
|
1243
|
+
const store = await readStore();
|
|
1244
|
+
const activeNum = String(store.activeNum);
|
|
1245
|
+
const activeEmail = String(store.emails[activeNum] ?? "").toLowerCase();
|
|
1246
|
+
// One address under two organizations is two accounts to claude-swap, so a
|
|
1247
|
+
// matching email in another org is still somebody else's slot.
|
|
1248
|
+
const sameOrg = !before.orgId || !store.orgs[activeNum] || before.orgId === store.orgs[activeNum];
|
|
1249
|
+
if (who !== activeEmail || !sameOrg || (expect != null && who !== String(expect).toLowerCase())) {
|
|
1250
|
+
return { ok: false, reason: "not_active_account", email: before.email };
|
|
1251
|
+
}
|
|
1222
1252
|
|
|
1223
1253
|
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
1224
1254
|
if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
|
|
@@ -1242,6 +1272,69 @@ export async function recaptureActive() {
|
|
|
1242
1272
|
});
|
|
1243
1273
|
}
|
|
1244
1274
|
|
|
1275
|
+
// ββ the same repair, without the press ββββββββββββββββββββββββββββββββββββββ
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* How long one account is left alone after the deck re-captured it on its own.
|
|
1279
|
+
*
|
|
1280
|
+
* Ten minutes: past claude-swap's longest ordinary poll interval, so a capture
|
|
1281
|
+
* that took has been collected before this could fire again, and one that did
|
|
1282
|
+
* not β a collector that still cannot read the login β costs one `claude auth
|
|
1283
|
+
* status` and one `cswap add` per ten minutes rather than one per panel poll.
|
|
1284
|
+
*/
|
|
1285
|
+
const AUTO_RECAPTURE_RETRY_MS = 10 * 60_000;
|
|
1286
|
+
|
|
1287
|
+
/** Emails whose automatic attempt is under way. Per account rather than one
|
|
1288
|
+
* flag: after an auto-switch a second account can pause while the first's
|
|
1289
|
+
* attempt still waits on the store lock, and it must not borrow that attempt's
|
|
1290
|
+
* `running`. The lock serialises the attempts themselves. */
|
|
1291
|
+
const _autoRunning = new Set();
|
|
1292
|
+
/** Lower-cased email β the last automatic attempt: when, and why it failed. */
|
|
1293
|
+
const _autoTried = new Map();
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* What the deck's own re-capture of a paused account should do now, given its
|
|
1297
|
+
* last attempt. Pure; exported for tests.
|
|
1298
|
+
*
|
|
1299
|
+
* running an attempt is under way
|
|
1300
|
+
* start none has been made, or the last is old enough to try again
|
|
1301
|
+
* failed one was made inside the window and the row is still paused;
|
|
1302
|
+
* `reason` is why it failed, or null when the capture itself held
|
|
1303
|
+
*/
|
|
1304
|
+
export function autoRecaptureState(last, now, { inFlight = false, retryMs = AUTO_RECAPTURE_RETRY_MS } = {}) {
|
|
1305
|
+
if (inFlight) return { state: "running" };
|
|
1306
|
+
if (!last || now - last.at >= retryMs) return { state: "start" };
|
|
1307
|
+
return { state: "failed", reason: last.reason ?? null, retryAt: last.at + retryMs };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Re-capture a paused account because a read found it paused β the repair the
|
|
1312
|
+
* `resume` button used to wait for.
|
|
1313
|
+
*
|
|
1314
|
+
* Synchronous for its caller, which is building a roster row: it starts the
|
|
1315
|
+
* attempt and returns the state to draw. At most one attempt per account under
|
|
1316
|
+
* way and one per AUTO_RECAPTURE_RETRY_MS, always for the account the row names,
|
|
1317
|
+
* which recaptureActive checks against who is signed in inside the store lock.
|
|
1318
|
+
*/
|
|
1319
|
+
export function autoRecapture({ email, now = Date.now() } = {}) {
|
|
1320
|
+
const key = String(email ?? "").trim().toLowerCase();
|
|
1321
|
+
if (!key) return null;
|
|
1322
|
+
const next = autoRecaptureState(_autoTried.get(key), now, { inFlight: _autoRunning.has(key) });
|
|
1323
|
+
if (next.state !== "start") return next;
|
|
1324
|
+
_autoRunning.add(key);
|
|
1325
|
+
_autoTried.set(key, { at: now, reason: null });
|
|
1326
|
+
recaptureActive({ expect: key })
|
|
1327
|
+
.then(
|
|
1328
|
+
r => { if (!r?.ok) _autoTried.set(key, { at: now, reason: r?.reason ?? "failed" }); },
|
|
1329
|
+
() => { _autoTried.set(key, { at: now, reason: "failed" }); },
|
|
1330
|
+
)
|
|
1331
|
+
// And the roster forgets the row it drew as `running`, so the next read β
|
|
1332
|
+
// forced or not β says how the attempt went, instead of the cache or a
|
|
1333
|
+
// forced read's one-minute floor holding `resumingβ¦` over a failure.
|
|
1334
|
+
.finally(() => { _autoRunning.delete(key); invalidateClaudeAccountsCache(); });
|
|
1335
|
+
return { state: "running" };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1245
1338
|
export async function removeAccount(num) {
|
|
1246
1339
|
const n = Number(num);
|
|
1247
1340
|
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|