agent-dag 3.18.3 ā 3.20.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 +63 -1
- package/bin/agent-dag.js +125 -4
- package/bin/deck.js +532 -4
- package/dist/web/assets/index-DVj_M279.css +1 -0
- package/dist/web/assets/index-Dr-bVaHM.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/args.mjs +27 -0
- package/src/server/claude-accounts.mjs +173 -6
- package/src/server/cswap-admin.mjs +17 -0
- package/src/server/deck-home.mjs +243 -0
- package/src/server/deck-prefs.mjs +37 -7
- 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 +125 -86
- package/src/server/installer.mjs +33 -8
- package/src/server/lan-engine.mjs +53 -3
- package/src/server/lan-socket.mjs +94 -11
- package/src/server/login-service.mjs +473 -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-BeMPJP5g.css +0 -1
- package/dist/web/assets/index-gg-y6G2I.js +0 -260
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-Dr-bVaHM.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DVj_M279.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.20.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.20.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "š¢ The deck keeps running after you close the terminal",
|
|
37
|
+
"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."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"3.19.0": [
|
|
41
|
+
{
|
|
42
|
+
"title": "š¢ Local network: fixes and improvements",
|
|
43
|
+
"body": "The list of decks is quiet now. A deck that is **on**, with nothing to repair, is a name and a green mark ā no sentence under it. Every healthy row used to carry the same words, so the one row worth reading looked like all the others. A failure, a login that arrived, a machine that is away, still say so, and they are now the only rows with anything written on them.\n\n`unpair` arrives when you point at a row rather than sitting on every one of them, two machines that share a name show their addresses, and the ā» turns while a round is out.\n\n**Add a deck** is two ways in ā by address, or with an invite ā with `make one to send` beside the invite heading instead of under the paste field, where it read as that field's button. A refused address now says which mistake it was, and an invite that will not work says whether it ran out, was not an invite, or reached nothing.\n\nSwitching **Local network** on opens your name and shared logins, every time, so nothing goes on the network without you having just seen what goes with it."
|
|
44
|
+
}
|
|
45
|
+
],
|
|
34
46
|
"3.18.3": [
|
|
35
47
|
{
|
|
36
48
|
"title": "š¢ The list shows what is on, and folds the rest",
|
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;
|
|
@@ -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"));
|
|
@@ -449,7 +581,9 @@ async function readRoster(now, gen) {
|
|
|
449
581
|
|
|
450
582
|
const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
|
|
451
583
|
const isActive = String(seq.activeAccountNumber) === num;
|
|
452
|
-
const trouble = authTrouble(row, {
|
|
584
|
+
const trouble = authTrouble(row, {
|
|
585
|
+
matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
|
|
586
|
+
});
|
|
453
587
|
|
|
454
588
|
const lanes = [
|
|
455
589
|
lane("five_hour", "5h", good?.five_hour),
|
|
@@ -502,6 +636,17 @@ async function readRoster(now, gen) {
|
|
|
502
636
|
// in as it anyway. The panel says so quietly instead of offering to log
|
|
503
637
|
// them in again.
|
|
504
638
|
staleCopy: trouble?.kind === "stale-copy",
|
|
639
|
+
// Nothing has been collected for this account in half a day, and nothing
|
|
640
|
+
// says why. Its own word because the two existing ones would both be
|
|
641
|
+
// wrong: `error` claims a rejection that was never reported, and
|
|
642
|
+
// `staleCopy` promises the user is signed in as it, which is only
|
|
643
|
+
// knowable for the active account.
|
|
644
|
+
stopped: trouble?.kind === "stopped",
|
|
645
|
+
// claude-swap's own verdict for this slot, when there is a fresh one:
|
|
646
|
+
// "no_credentials", "relogin_required", "keychain_unavailable", ⦠It is
|
|
647
|
+
// what turns "not collecting" into a sentence with a next step in it, and
|
|
648
|
+
// it is null on every machine where the collector has not been asked yet.
|
|
649
|
+
collector: verdictFor(num, now),
|
|
505
650
|
});
|
|
506
651
|
}
|
|
507
652
|
|
|
@@ -541,8 +686,24 @@ async function readRoster(now, gen) {
|
|
|
541
686
|
* evidence of anything, so the stored verdict stands: refusing to show a real
|
|
542
687
|
* expiry because a subprocess failed is the opposite mistake.
|
|
543
688
|
*/
|
|
544
|
-
export function authTrouble(row, {
|
|
545
|
-
|
|
689
|
+
export function authTrouble(row, {
|
|
690
|
+
matches, isActive, identity, email,
|
|
691
|
+
fetchedAt = null, now = Date.now(), stoppedAfterMs = COLLECTION_STOPPED_AFTER_MS,
|
|
692
|
+
} = {}) {
|
|
693
|
+
if (!matches) return null;
|
|
694
|
+
const failing = (row?.consecutiveFailures ?? 0) > 0;
|
|
695
|
+
// A COLLECTOR THAT HAS PRODUCED NOTHING IN HALF A DAY IS FAILING, whatever
|
|
696
|
+
// its counter says ā see COLLECTION_STOPPED_AFTER_MS. `consecutiveFailures`
|
|
697
|
+
// counts rejections, and the failure found on the reporting machine was not a
|
|
698
|
+
// rejection: `cswap list` answered `usageStatus: keychain_unavailable` for
|
|
699
|
+
// three accounts whose counters all read zero.
|
|
700
|
+
//
|
|
701
|
+
// `fetchedAt == null` is deliberately NOT this. That is an account nobody has
|
|
702
|
+
// ever collected ā usually one added a minute ago ā and the panel already has
|
|
703
|
+
// a word for it. Calling a new account broken is a worse first impression than
|
|
704
|
+
// saying nothing.
|
|
705
|
+
const stopped = !failing && fetchedAt != null && now - fetchedAt > stoppedAfterMs;
|
|
706
|
+
if (!failing && !stopped) return null;
|
|
546
707
|
|
|
547
708
|
const signedInHere = isActive
|
|
548
709
|
&& identity
|
|
@@ -555,6 +716,12 @@ export function authTrouble(row, { matches, isActive, identity, email } = {}) {
|
|
|
555
716
|
// fix under a red badge, and it must not offer to sign them in again.
|
|
556
717
|
return { kind: "stale-copy", error: null };
|
|
557
718
|
}
|
|
719
|
+
// A SILENCE, NOT A DIAGNOSIS. All that is known is that nothing has been
|
|
720
|
+
// collected for half a day; the reason lives in claude-swap and may be a dead
|
|
721
|
+
// login, a keychain it cannot open, or a machine that was off. Putting
|
|
722
|
+
// `invalid_grant` on it would be inventing evidence, and the panel would then
|
|
723
|
+
// offer "sign in again" for a problem that may not be a sign-in at all.
|
|
724
|
+
if (stopped) return { kind: "stopped", error: null };
|
|
558
725
|
return { kind: "auth", error: row.lastError ?? "error" };
|
|
559
726
|
}
|
|
560
727
|
|
|
@@ -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
|
*
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Where the deck keeps its OWN things, as opposed to where Claude Code keeps
|
|
2
|
+
// its things.
|
|
3
|
+
//
|
|
4
|
+
// For a year those were one directory. `~/.claude/agent-dag/` held the hook
|
|
5
|
+
// Claude Code reads, the deck's private key, its pairings, its port registry
|
|
6
|
+
// and a hundred megabytes of event log ā and the only reason was that the deck
|
|
7
|
+
// already knew that path, because it has to read `~/.claude` to do its job at
|
|
8
|
+
// all.
|
|
9
|
+
//
|
|
10
|
+
// THREE THINGS THAT COST, and none of them is tidiness:
|
|
11
|
+
//
|
|
12
|
+
// A person who deletes ~/.claude to fix Claude Code deletes this deck's
|
|
13
|
+
// identity. The key is what every paired machine has pinned, so it is not a
|
|
14
|
+
// setting that comes back ā every colleague has to accept this deck again.
|
|
15
|
+
//
|
|
16
|
+
// CLAUDE_CONFIG_DIR is Claude Code's knob for keeping a second profile, and
|
|
17
|
+
// pointing it somewhere gave the deck a brand new identity and no pairings,
|
|
18
|
+
// silently. That is a footgun with a fifty-machine blast radius and no error
|
|
19
|
+
// message anywhere.
|
|
20
|
+
//
|
|
21
|
+
// The event log is a hundred megabytes of the deck's data inside another
|
|
22
|
+
// application's configuration directory. Nothing about a rotating log is
|
|
23
|
+
// configuration.
|
|
24
|
+
//
|
|
25
|
+
// So the deck's own state moves to where this platform keeps application data,
|
|
26
|
+
// and the two things that CANNOT move stay where they are: `hook.js`, because
|
|
27
|
+
// Claude Code reads it from there, and the per-pid port registry, because a
|
|
28
|
+
// deck of any version has to be able to list the others ā see
|
|
29
|
+
// registeredDeckPorts, which reads that directory by name.
|
|
30
|
+
//
|
|
31
|
+
// The layout is env-paths', which is what Node command-line tools converge on,
|
|
32
|
+
// and its rule is the one worth repeating: XDG is a Linux specification, so it
|
|
33
|
+
// applies on Linux and nowhere else. macOS has Application Support and Logs;
|
|
34
|
+
// Windows has LocalAppData. A tool that puts ~/.config on a Mac is a tool that
|
|
35
|
+
// read half the page.
|
|
36
|
+
//
|
|
37
|
+
// NOTHING IS DELETED BY THE MOVE. Every file is COPIED to its new home and the
|
|
38
|
+
// original left where it was, so a deck downgraded to the version before this
|
|
39
|
+
// one finds exactly what it had. See migrateDeckFiles.
|
|
40
|
+
import { homedir } from "node:os";
|
|
41
|
+
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
42
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
43
|
+
// The Windows ladder, not `rename` (#786). MoveFileExW refuses while any other
|
|
44
|
+
// process holds the destination open ā a scanner, the indexer, a backup agent ā
|
|
45
|
+
// and the file this one renames into place is a private key. Losing that rename
|
|
46
|
+
// to a virus scanner would be a deck with no identity, which is the one outcome
|
|
47
|
+
// this whole module is written to prevent.
|
|
48
|
+
import { renameWithRetry } from "./installer.mjs";
|
|
49
|
+
|
|
50
|
+
/** The name this deck files itself under. No `-nodejs` suffix, which env-paths
|
|
51
|
+
* adds to keep a script from colliding with a native app of the same name ā
|
|
52
|
+
* there is no other ccdeck on any of these platforms. */
|
|
53
|
+
const APP = "ccdeck";
|
|
54
|
+
|
|
55
|
+
/** Point the deck's whole home somewhere else in one variable. The escape
|
|
56
|
+
* hatch for a portable install, a test, or anybody who simply wants it
|
|
57
|
+
* elsewhere; it wins over every rule below. */
|
|
58
|
+
export const HOME_ENV = "CCDECK_HOME";
|
|
59
|
+
|
|
60
|
+
/** Where the deck kept everything before this, and where two of its files still
|
|
61
|
+
* live. Also the place a migration reads from. */
|
|
62
|
+
export function legacyDeckDir(env = process.env, home = homedir(), platform = process.platform) {
|
|
63
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
64
|
+
return join(claudeConfigDir(env, home), "agent-dag");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The deck's own durable state: its key, its pairings, the accounts it offers.
|
|
69
|
+
*
|
|
70
|
+
* CLAUDE_CONFIG_DIR IS HONOURED RATHER THAN OVERRIDDEN, and that is deliberate.
|
|
71
|
+
* Somebody who set it has asked for a separate Claude profile and has been
|
|
72
|
+
* getting a separate deck along with it for a year ā separate identity,
|
|
73
|
+
* separate pairings, separate share list. Moving those people to one
|
|
74
|
+
* machine-wide deck would collapse two profiles into one on upgrade, which is
|
|
75
|
+
* exactly the kind of silent change this file exists to stop. They keep what
|
|
76
|
+
* they have; everybody else moves.
|
|
77
|
+
*/
|
|
78
|
+
export function deckDataDir(platform = process.platform, env = process.env, home = homedir()) {
|
|
79
|
+
// THE TARGET PLATFORM'S SEPARATOR, NOT THE HOST'S ā the same reason
|
|
80
|
+
// claudeCliCandidates does it, which is that the only way a Windows answer
|
|
81
|
+
// stays right is if it can be checked from a Mac. `node:path` joins with
|
|
82
|
+
// whatever the machine running the code uses, so a bare `join` here answers
|
|
83
|
+
// `\home\u\Library\...` on Windows for a question about macOS.
|
|
84
|
+
// `resolve` for the same reason as `join` just below it: it is host-bound
|
|
85
|
+
// too, so a posix path resolved on a Windows runner comes back with the
|
|
86
|
+
// runner's drive letter glued to the front of it.
|
|
87
|
+
const { join, resolve } = platform === "win32" ? winPath : posixPath;
|
|
88
|
+
const forced = env[HOME_ENV]?.trim();
|
|
89
|
+
if (forced) return resolve(forced);
|
|
90
|
+
if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
|
|
91
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", APP);
|
|
92
|
+
if (platform === "win32") return join(localAppData(env, home, platform), APP, "Data");
|
|
93
|
+
return join(env.XDG_DATA_HOME?.trim() || join(home, ".local", "share"), APP);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where a log belongs, which is not where a preference belongs.
|
|
98
|
+
*
|
|
99
|
+
* On Linux that is the state directory rather than the data one: XDG separates
|
|
100
|
+
* "data the user would miss" from "state the program can rebuild", and an event
|
|
101
|
+
* log that rotates at 50 MB is the second. macOS and Windows have a named place
|
|
102
|
+
* for logs and this uses it.
|
|
103
|
+
*/
|
|
104
|
+
export function deckLogDir(platform = process.platform, env = process.env, home = homedir()) {
|
|
105
|
+
const { join, resolve } = platform === "win32" ? winPath : posixPath;
|
|
106
|
+
const forced = env[HOME_ENV]?.trim();
|
|
107
|
+
if (forced) return resolve(forced);
|
|
108
|
+
if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
|
|
109
|
+
if (platform === "darwin") return join(home, "Library", "Logs", APP);
|
|
110
|
+
if (platform === "win32") return join(localAppData(env, home, platform), APP, "Log");
|
|
111
|
+
return join(env.XDG_STATE_HOME?.trim() || join(home, ".local", "state"), APP);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** %LOCALAPPDATA%, or the path it points at on every Windows since Vista. The
|
|
115
|
+
* fallback is not decoration: a service account or a stripped environment can
|
|
116
|
+
* reach this code with the variable unset, and a deck that throws there is a
|
|
117
|
+
* deck that will not start. */
|
|
118
|
+
function localAppData(env = process.env, home = homedir(), platform = process.platform) {
|
|
119
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
120
|
+
return env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* WHAT MOVES, and the two things that do not.
|
|
125
|
+
*
|
|
126
|
+
* `hook.js` stays: Claude Code reads it out of ~/.claude/agent-dag and would
|
|
127
|
+
* find nothing if it moved. The per-pid `NNNNN.json` files stay: a deck lists
|
|
128
|
+
* that directory to find the other decks on this machine, and a deck of the
|
|
129
|
+
* previous version reads it by name ā move them and two versions running side
|
|
130
|
+
* by side stop seeing each other.
|
|
131
|
+
*
|
|
132
|
+
* `mode` is the file's, not the directory's, and 0600 on the preferences is
|
|
133
|
+
* load-bearing: it is the one file with a private key in it.
|
|
134
|
+
*/
|
|
135
|
+
export const MOVED = Object.freeze([
|
|
136
|
+
Object.freeze({ name: "prefs.json", where: "data", mode: 0o600 }),
|
|
137
|
+
Object.freeze({ name: "events.jsonl", where: "log", mode: 0o644 }),
|
|
138
|
+
Object.freeze({ name: "events.jsonl.1", where: "log", mode: 0o644 }),
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Bring a deck's files to their new home, once, and never at the cost of one.
|
|
143
|
+
*
|
|
144
|
+
* FOUR RULES, and each one is a way this could have lost somebody's key:
|
|
145
|
+
*
|
|
146
|
+
* A destination that already exists is never touched. That is what makes
|
|
147
|
+
* this safe to run at every boot, and it is also what stops a stale copy in
|
|
148
|
+
* the old directory from overwriting live state later.
|
|
149
|
+
*
|
|
150
|
+
* The copy lands on a temp file in the destination directory and is renamed
|
|
151
|
+
* into place. A copy interrupted half-way is a truncated prefs.json, and a
|
|
152
|
+
* truncated prefs.json is a deck with no key ā which is the whole thing this
|
|
153
|
+
* is trying not to do.
|
|
154
|
+
*
|
|
155
|
+
* The original is LEFT WHERE IT IS. Somebody who downgrades finds what they
|
|
156
|
+
* had. It costs four kilobytes and a hundred megabytes of log that was
|
|
157
|
+
* already there, and it buys a move nobody can be hurt by.
|
|
158
|
+
*
|
|
159
|
+
* A failure is reported and swallowed. A read-only home, a full disk, a
|
|
160
|
+
* permission the installer never had ā none of those is a reason for the
|
|
161
|
+
* deck not to start, and the caller falls back to reading the old path.
|
|
162
|
+
*
|
|
163
|
+
* Returns what it did, so the caller can say so once rather than every boot.
|
|
164
|
+
*/
|
|
165
|
+
export async function migrateDeckFiles({
|
|
166
|
+
from,
|
|
167
|
+
data,
|
|
168
|
+
log,
|
|
169
|
+
fs,
|
|
170
|
+
/** Separate from `fs` because it is not `fs.rename`: see the import above. */
|
|
171
|
+
rename = renameWithRetry,
|
|
172
|
+
onError,
|
|
173
|
+
} = {}) {
|
|
174
|
+
const moved = [];
|
|
175
|
+
const failed = [];
|
|
176
|
+
for (const file of MOVED) {
|
|
177
|
+
const dir = file.where === "log" ? log : data;
|
|
178
|
+
const src = join(from, file.name);
|
|
179
|
+
const dst = join(dir, file.name);
|
|
180
|
+
if (src === dst) continue;
|
|
181
|
+
try {
|
|
182
|
+
// Both questions asked before anything is written: there is something to
|
|
183
|
+
// move, and there is nothing already there.
|
|
184
|
+
if (!(await exists(fs, src))) continue;
|
|
185
|
+
if (await exists(fs, dst)) continue;
|
|
186
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
187
|
+
const tmp = `${dst}.${process.pid}.migrating`;
|
|
188
|
+
await fs.copyFile(src, tmp);
|
|
189
|
+
await fs.chmod(tmp, file.mode).catch(() => {});
|
|
190
|
+
await rename(tmp, dst);
|
|
191
|
+
moved.push(file.name);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
failed.push(file.name);
|
|
194
|
+
onError?.(file.name, err);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { moved, failed };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function exists(fs, path) {
|
|
201
|
+
try { await fs.stat(path); return true; } catch { return false; }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** An hour. A temp file younger than this may belong to a write happening in
|
|
205
|
+
* another deck right now, and deleting one of those turns an atomic write into
|
|
206
|
+
* a lost one. Nothing legitimate is an hour old: the rename that retires a
|
|
207
|
+
* temp file happens within milliseconds of its creation. */
|
|
208
|
+
export const TEMP_STALE_MS = 60 * 60 * 1000;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The litter an atomic write leaves when the process is killed between the
|
|
212
|
+
* write and the rename.
|
|
213
|
+
*
|
|
214
|
+
* Ninety-seven of them were found in one directory, the oldest six days old:
|
|
215
|
+
* two waves, one from decks that crashed and one from decks killed by hand
|
|
216
|
+
* mid-write. Nothing has ever swept them, because the code that makes them is
|
|
217
|
+
* the code that is not running any more when they are made.
|
|
218
|
+
*
|
|
219
|
+
* So the sweep belongs at START, where a process that is alive can clean up
|
|
220
|
+
* after the ones that are not ā and it is deliberately blind to WHICH deck made
|
|
221
|
+
* a file, because it cannot know: it goes by age instead, which is a fact about
|
|
222
|
+
* the file rather than a guess about a pid.
|
|
223
|
+
*/
|
|
224
|
+
export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {}) {
|
|
225
|
+
let removed = 0;
|
|
226
|
+
for (const dir of dirs ?? []) {
|
|
227
|
+
let names;
|
|
228
|
+
try { names = await fs.readdir(dir); } catch { continue; }
|
|
229
|
+
for (const name of names) {
|
|
230
|
+
if (!name.endsWith(".tmp") && !name.endsWith(".migrating")) continue;
|
|
231
|
+
const path = join(dir, name);
|
|
232
|
+
try {
|
|
233
|
+
const st = await fs.stat(path);
|
|
234
|
+
if (now - st.mtimeMs < TEMP_STALE_MS) continue;
|
|
235
|
+
await fs.unlink(path);
|
|
236
|
+
removed++;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
onError?.(path, err);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return removed;
|
|
243
|
+
}
|