ccdeck 3.28.1 → 3.29.1
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 +4 -0
- package/THIRD_PARTY_NOTICES.md +148 -4
- package/dist/web/assets/{BrowserWatchModal-CIla-_hm.js → BrowserWatchModal-C-TkzDPi.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CSgh5kjS.js → UsageHistoryModal-FKBbTozZ.js} +1 -1
- package/dist/web/assets/hls-BFpXDghs.js +42 -0
- package/dist/web/assets/index-7mOqoJ3Y.js +298 -0
- package/dist/web/assets/index-FeNTTJ0x.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/release-notes.json +36 -0
- package/src/server/account-health.mjs +38 -0
- package/src/server/account-projects.mjs +89 -23
- package/src/server/claude-accounts.mjs +84 -26
- package/src/server/claude-dir.mjs +35 -0
- package/src/server/cswap-admin.mjs +136 -8
- package/src/server/deck-prefs.mjs +2 -0
- package/src/server/fm-station.mjs +186 -0
- package/src/server/index.mjs +208 -55
- package/src/server/lan-engine.mjs +244 -38
- package/src/server/lan-socket.mjs +101 -15
- package/src/server/lan-sync.mjs +86 -9
- package/dist/web/assets/index-CsHTYwoD.js +0 -297
- package/dist/web/assets/index-DbwzUUcd.css +0 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** A collector's verdict is about the stored copy, not the live CLI session.
|
|
2
|
+
* In particular, an inaccessible Keychain does not make the stored copy dead:
|
|
3
|
+
* treating it as dead starts a futile peer-healing loop on every LAN round. */
|
|
4
|
+
const LOST_COPY = new Set(["no_credentials", "relogin_required", "foreign_credential"]);
|
|
5
|
+
|
|
6
|
+
export function storedCopyAlive(alive, verdict) {
|
|
7
|
+
// An unreadable keychain or a deferred token refresh is a failure to CHECK,
|
|
8
|
+
// not evidence that the stored login has died. Do not repeatedly overwrite
|
|
9
|
+
// that slot with copies from a peer while the check is unavailable.
|
|
10
|
+
if (verdict === "keychain_unavailable" || verdict === "token_expired") return true;
|
|
11
|
+
return alive === true && !LOST_COPY.has(verdict);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** When a collector verdict is present, only `ok` is positive export evidence.
|
|
15
|
+
* Unknown and transient verdict strings are therefore fail-closed. */
|
|
16
|
+
export function exportVerdictOk(verdict) {
|
|
17
|
+
return verdict === "ok";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cached preflight for the LAN hot path. A KNOWN bad or unknown verdict is
|
|
21
|
+
* refused before spawning an export. An inactive slot with no verdict may reach
|
|
22
|
+
* the bounded export itself: it exports the stored copy, which carries the
|
|
23
|
+
* slot's own identity. The ACTIVE slot may not, because its export is the live
|
|
24
|
+
* CLI login, which can belong to another account or be expired while the
|
|
25
|
+
* blob's labels still name the slot, so the identity check cannot catch it.
|
|
26
|
+
* Its verdict is refreshed in the background and the next round shares it. */
|
|
27
|
+
export function cachedExportReadable(verdict, { active = false } = {}) {
|
|
28
|
+
if (verdict == null) return !active;
|
|
29
|
+
return exportVerdictOk(verdict);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Is the live CLI login the account this slot names? The active slot exports
|
|
33
|
+
* the live login, and a verdict can predate a `/login` as somebody else. */
|
|
34
|
+
export function liveLoginIs(identity, email, org) {
|
|
35
|
+
const live = String(identity?.email ?? "").trim().toLowerCase();
|
|
36
|
+
if (!live || live !== String(email ?? "").trim().toLowerCase()) return false;
|
|
37
|
+
return !identity.orgId || !org || identity.orgId === org;
|
|
38
|
+
}
|
|
@@ -15,17 +15,18 @@
|
|
|
15
15
|
// accounts; only the message's own timestamp, matched against the swap log,
|
|
16
16
|
// says who was active when it was billed. Each transcript line carries, in one
|
|
17
17
|
// JSON object, its `timestamp`, its `message.model` and its `message.usage`
|
|
18
|
-
// token counts, and its `cwd` — everything a row needs
|
|
18
|
+
// token counts, and its `cwd` — everything a row needs, once the `cwd` is taken
|
|
19
|
+
// back to the folder the session started in (see sessionFolder).
|
|
19
20
|
//
|
|
20
21
|
// The scan is incremental: a byte cursor per transcript file means a pass folds
|
|
21
22
|
// only the lines appended since the last one, so opening the report is a read
|
|
22
23
|
// of a small on-disk tally rather than a walk of every transcript.
|
|
23
24
|
import { open, stat, readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
24
25
|
import { homedir } from "node:os";
|
|
25
|
-
import { join, resolve, basename, dirname } from "node:path";
|
|
26
|
+
import { join, resolve, basename, dirname, relative } from "node:path";
|
|
26
27
|
import { StringDecoder } from "node:string_decoder";
|
|
27
28
|
import { renameWithRetry } from "./installer.mjs";
|
|
28
|
-
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
29
|
+
import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
|
|
29
30
|
import { accountKey } from "./lan-sync.mjs";
|
|
30
31
|
import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
|
|
31
32
|
|
|
@@ -34,7 +35,7 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
|
|
|
34
35
|
* a per-account total is never quietly inflated by work that is not that
|
|
35
36
|
* account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
|
|
36
37
|
export const UNATTRIBUTED = "\u0000unattributed";
|
|
37
|
-
const STATE_VERSION =
|
|
38
|
+
const STATE_VERSION = 3;
|
|
38
39
|
|
|
39
40
|
/** Where the tally and cursors live, beside cswap-auto's own state. */
|
|
40
41
|
export function statePath(home = homedir()) {
|
|
@@ -113,6 +114,51 @@ function cwdFromSlug(slug) {
|
|
|
113
114
|
return "/" + slug.replace(/^-+/, "").replace(/-/g, "/");
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The folder a transcript line's session started in, recovered exactly from the
|
|
119
|
+
* line's own `cwd` and the slug of the project directory the transcript sits in.
|
|
120
|
+
*
|
|
121
|
+
* CC writes the shell's CURRENT directory on every line, so an agent that ran
|
|
122
|
+
* `cd src/web` stamps every later line with that subfolder. Keying the tally by
|
|
123
|
+
* it split one project into a row for every folder its agent passed through —
|
|
124
|
+
* 25 rows for one repo over a month (#1278). The folder CC files the transcript
|
|
125
|
+
* under, ~/.claude/projects/<slug>, is the one the session started in, and the
|
|
126
|
+
* canvas names a session after the same one. The slug cannot be decoded (a dash
|
|
127
|
+
* in a folder name reads as a separator), but it can be MATCHED: the start
|
|
128
|
+
* folder is the nearest ancestor of the line's cwd, the cwd itself included,
|
|
129
|
+
* that encodes to that slug.
|
|
130
|
+
*
|
|
131
|
+
* Null when none does: the agent left its folder (`cd ../elsewhere`), or the
|
|
132
|
+
* slug is CC's hashed long form of a path this cwd is not under.
|
|
133
|
+
*/
|
|
134
|
+
export function sessionFolder(cwd, slug) {
|
|
135
|
+
if (!cwd || !slug) return null;
|
|
136
|
+
for (let dir = cwd; ;) {
|
|
137
|
+
if (ccProjectSlug(dir) === slug) return dir;
|
|
138
|
+
const up = dirname(dir);
|
|
139
|
+
if (up === dir) return null;
|
|
140
|
+
dir = up;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Which folder a line counts under. `from` describes the transcript the line
|
|
146
|
+
* came from: `slug` is its project directory's name, and `folders` maps each
|
|
147
|
+
* slug to the start folder last recovered for it. The map is shared by every
|
|
148
|
+
* transcript of one project directory and persisted, so a line written after
|
|
149
|
+
* the agent left its folder still counts under that folder. Without a slug (a
|
|
150
|
+
* direct call, as the unit tests make) the line's own cwd is used unchanged.
|
|
151
|
+
*/
|
|
152
|
+
function folderOf(lineCwd, { slug = "", folders = null } = {}) {
|
|
153
|
+
const found = sessionFolder(lineCwd, slug);
|
|
154
|
+
if (found) {
|
|
155
|
+
if (folders) folders[slug] = found;
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
if (slug && folders?.[slug]) return folders[slug];
|
|
159
|
+
return lineCwd || cwdFromSlug(slug);
|
|
160
|
+
}
|
|
161
|
+
|
|
116
162
|
/**
|
|
117
163
|
* The project a cwd belongs to. A git worktree lives at
|
|
118
164
|
* `<repo>/.claude/worktrees/<name>`, so work done in one is counted under the
|
|
@@ -135,8 +181,9 @@ export function projectPath(cwd) {
|
|
|
135
181
|
*
|
|
136
182
|
* `timeline` is the sorted swap log; the account is whoever it says was active
|
|
137
183
|
* at this line's timestamp, or UNATTRIBUTED when the timestamp precedes it.
|
|
184
|
+
* `from` names the transcript's project directory — see folderOf.
|
|
138
185
|
*/
|
|
139
|
-
export function foldLine(tally, line, timeline,
|
|
186
|
+
export function foldLine(tally, line, timeline, from) {
|
|
140
187
|
if (!line || !line.includes('"usage"')) return;
|
|
141
188
|
let obj = null;
|
|
142
189
|
try { obj = JSON.parse(line); } catch { return; }
|
|
@@ -151,8 +198,8 @@ export function foldLine(tally, line, timeline, fallbackCwd) {
|
|
|
151
198
|
const c = countersFrom(usage);
|
|
152
199
|
if (!(c.i || c.o || c.cr || c.cc)) return; // a usage block that billed nothing
|
|
153
200
|
const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
|
|
154
|
-
const
|
|
155
|
-
const cwd = projectPath(
|
|
201
|
+
const lineCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : "";
|
|
202
|
+
const cwd = projectPath(folderOf(lineCwd, from)); // a worktree counts under the repo it checks out
|
|
156
203
|
const who = accountAtTime(timeline, ts);
|
|
157
204
|
const key = who ? accountKey(who.email, who.orgUuid) : UNATTRIBUTED;
|
|
158
205
|
const day = localDay(ts);
|
|
@@ -187,19 +234,32 @@ async function foldAppended(fh, start, size, onLine) {
|
|
|
187
234
|
return size - Buffer.byteLength(carry, "utf8");
|
|
188
235
|
}
|
|
189
236
|
|
|
190
|
-
/** Every `.jsonl` under the transcript roots, de-duplicated
|
|
237
|
+
/** Every `.jsonl` under the transcript roots, de-duplicated, mapped to the
|
|
238
|
+
* slug of the project directory it sits in: the first folder below the root,
|
|
239
|
+
* also for a subagent's `<slug>/<session>/subagents/agent-*.jsonl`.
|
|
240
|
+
*
|
|
241
|
+
* Shallowest first, so a session's own transcript, whose first line carries
|
|
242
|
+
* the folder it started in, is folded before its subagents' and a subagent
|
|
243
|
+
* that only ever worked outside that folder still finds it in `folders`. */
|
|
191
244
|
async function listTranscripts(roots) {
|
|
192
|
-
const
|
|
245
|
+
const found = [];
|
|
246
|
+
const seen = new Set();
|
|
193
247
|
for (const root of roots) {
|
|
194
248
|
let ents = null;
|
|
195
249
|
try { ents = await readdir(root, { recursive: true, withFileTypes: true }); }
|
|
196
250
|
catch { continue; }
|
|
197
251
|
for (const e of ents) {
|
|
198
252
|
if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
|
|
199
|
-
|
|
253
|
+
const dir = e.parentPath ?? e.path ?? root;
|
|
254
|
+
const path = join(dir, e.name);
|
|
255
|
+
if (seen.has(path)) continue;
|
|
256
|
+
seen.add(path);
|
|
257
|
+
const parts = relative(root, dir).split(/[\\/]/).filter(Boolean);
|
|
258
|
+
found.push({ path, slug: parts[0] ?? "", depth: parts.length });
|
|
200
259
|
}
|
|
201
260
|
}
|
|
202
|
-
|
|
261
|
+
found.sort((a, b) => a.depth - b.depth);
|
|
262
|
+
return new Map(found.map(f => [f.path, f.slug]));
|
|
203
263
|
}
|
|
204
264
|
|
|
205
265
|
/** Drop day buckets older than the retention window and prune the empty
|
|
@@ -316,7 +376,7 @@ export function createProjectRollup({
|
|
|
316
376
|
setInterval: setIv = setInterval,
|
|
317
377
|
clearInterval: clearIv = clearInterval,
|
|
318
378
|
} = {}) {
|
|
319
|
-
let state = { version: STATE_VERSION, cursors: {}, tally: {} };
|
|
379
|
+
let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {} };
|
|
320
380
|
let timer = null;
|
|
321
381
|
let running = false;
|
|
322
382
|
let dirty = false;
|
|
@@ -329,10 +389,14 @@ export function createProjectRollup({
|
|
|
329
389
|
const disk = JSON.parse(await readFile(stateFile, "utf8"));
|
|
330
390
|
if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
|
|
331
391
|
state = disk;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
|
|
392
|
+
state.folders ??= {};
|
|
393
|
+
} else if (disk && (disk.version === 1 || disk.version === 2)) {
|
|
394
|
+
// Version 1 counted every API content block; version 2 keyed each row
|
|
395
|
+
// by the line's own cwd, a row per folder the agent cd'd into (#1278).
|
|
396
|
+
// Neither tally can be re-keyed, since it no longer knows which
|
|
397
|
+
// transcript a count came from. Keep the heartbeat so a restart gap
|
|
398
|
+
// remains fenced, but rebuild the tally from transcripts.
|
|
399
|
+
state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, lastAlive: disk.lastAlive };
|
|
336
400
|
dirty = true;
|
|
337
401
|
}
|
|
338
402
|
} catch { /* first run: empty state */ }
|
|
@@ -353,7 +417,7 @@ export function createProjectRollup({
|
|
|
353
417
|
} catch { /* a failed persist just re-folds next time; cursors stay in memory */ }
|
|
354
418
|
}
|
|
355
419
|
|
|
356
|
-
async function foldFile(path, timeline) {
|
|
420
|
+
async function foldFile(path, slug, timeline) {
|
|
357
421
|
let st = null;
|
|
358
422
|
try { st = await stat(path); } catch { return; }
|
|
359
423
|
let offset = state.cursors[path] ?? 0;
|
|
@@ -364,11 +428,11 @@ export function createProjectRollup({
|
|
|
364
428
|
// is the rare, safe side to err on.
|
|
365
429
|
if (st.size < offset) { state.cursors[path] = st.size; dirty = true; return; }
|
|
366
430
|
if (st.size <= offset) return;
|
|
367
|
-
const
|
|
431
|
+
const from = { slug, folders: state.folders };
|
|
368
432
|
let fh = null;
|
|
369
433
|
try {
|
|
370
434
|
fh = await open(path, "r");
|
|
371
|
-
const consumed = await foldAppended(fh, offset, st.size, (line) => foldLine(state.tally, line, timeline,
|
|
435
|
+
const consumed = await foldAppended(fh, offset, st.size, (line) => foldLine(state.tally, line, timeline, from));
|
|
372
436
|
if (consumed > offset) { state.cursors[path] = consumed; dirty = true; }
|
|
373
437
|
} catch { /* unreadable this pass; cursor unchanged, tried again next pass */ }
|
|
374
438
|
finally { if (fh) await fh.close().catch(() => {}); }
|
|
@@ -391,10 +455,12 @@ export function createProjectRollup({
|
|
|
391
455
|
await seedActive({ now, path: swapLog, root: storeRoot }); // anchor the current account, once, deduped
|
|
392
456
|
const timeline = await readSwapLog(swapLog);
|
|
393
457
|
const files = await listTranscripts(roots);
|
|
394
|
-
for (const path of files) await foldFile(path, timeline);
|
|
395
|
-
// Forget cursors for files that are gone,
|
|
396
|
-
|
|
397
|
-
for (const p of Object.keys(state.cursors)) if (!
|
|
458
|
+
for (const [path, slug] of files) await foldFile(path, slug, timeline);
|
|
459
|
+
// Forget cursors for files that are gone, and the folders of project
|
|
460
|
+
// directories with no transcript left, so neither map grows forever.
|
|
461
|
+
for (const p of Object.keys(state.cursors)) if (!files.has(p)) { delete state.cursors[p]; dirty = true; }
|
|
462
|
+
const slugs = new Set(files.values());
|
|
463
|
+
for (const s of Object.keys(state.folders)) if (!slugs.has(s)) { delete state.folders[s]; dirty = true; }
|
|
398
464
|
// Heartbeat: record that the deck was alive now, so a later boot can see
|
|
399
465
|
// how long it was down. Cheap when nothing else changed — only bumped
|
|
400
466
|
// (and so only persisted) once every HEARTBEAT_MS while idle.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
17
17
|
import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
|
|
18
18
|
import { run, runDetached } from "./exec.mjs";
|
|
19
|
+
import { storedCopyAlive } from "./account-health.mjs";
|
|
19
20
|
// The CLI identity oracle, already written and already trusted by the account
|
|
20
21
|
// admin routes. #721 needs the same answer, so it reuses the same function
|
|
21
22
|
// rather than shelling out a second way to ask one question.
|
|
@@ -347,14 +348,39 @@ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
|
|
|
347
348
|
* for `--json` and keeps the verdicts. Still not awaited by anyone — the nudge
|
|
348
349
|
* stays synchronous for its callers — and still one child at a time.
|
|
349
350
|
*/
|
|
350
|
-
let _verdicts = { at: 0, byNum: {} };
|
|
351
|
+
let _verdicts = { at: 0, byNum: {}, identities: {} };
|
|
351
352
|
/** Stale after this, because a verdict that outlives its cause is worse than no
|
|
352
353
|
* verdict: "no credentials" under an account somebody has since signed into is
|
|
353
354
|
* a sentence that sends them to fix what is already fixed. */
|
|
354
355
|
const VERDICT_TTL_MS = 10 * 60_000;
|
|
355
|
-
/**
|
|
356
|
-
*
|
|
357
|
-
|
|
356
|
+
/** Run one `cswap list --json` at a time. A post-write question must start
|
|
357
|
+
* after any older collection has finished; routine readers can share the
|
|
358
|
+
* latest pending answer without starting another slow collection. */
|
|
359
|
+
export function createVerdictQueue(collect) {
|
|
360
|
+
let pending = null;
|
|
361
|
+
return {
|
|
362
|
+
ask({ fresh = false } = {}) {
|
|
363
|
+
if (pending && !fresh) return pending;
|
|
364
|
+
const previous = pending;
|
|
365
|
+
// Start the first poll immediately: requestCollection promises the UI that
|
|
366
|
+
// its refresh has started before it returns. Only subsequent fresh polls
|
|
367
|
+
// wait for the earlier snapshot to finish.
|
|
368
|
+
let next;
|
|
369
|
+
if (previous) next = previous.then(collect, collect);
|
|
370
|
+
else {
|
|
371
|
+
try { next = Promise.resolve(collect()); }
|
|
372
|
+
catch (error) { next = Promise.reject(error); }
|
|
373
|
+
}
|
|
374
|
+
pending = next;
|
|
375
|
+
void next.then(
|
|
376
|
+
() => { if (pending === next) pending = null; },
|
|
377
|
+
() => { if (pending === next) pending = null; },
|
|
378
|
+
);
|
|
379
|
+
return next;
|
|
380
|
+
},
|
|
381
|
+
busy() { return pending !== null; },
|
|
382
|
+
};
|
|
383
|
+
}
|
|
358
384
|
|
|
359
385
|
/**
|
|
360
386
|
* Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
|
|
@@ -374,6 +400,20 @@ let _verdictInFlight = false;
|
|
|
374
400
|
export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
|
|
375
401
|
const want = String(email ?? "").trim().toLowerCase();
|
|
376
402
|
if (!want) return null;
|
|
403
|
+
const all = await verdictsNow({ runner, bin, fresh: true });
|
|
404
|
+
return all?.find(a => a.email === want && a.org === (org ?? ""))?.status ?? null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Every account's verdict from ONE `cswap list --json`, as `{ email, org,
|
|
409
|
+
* status, active }` rows (email lower-cased, status null when claude-swap gave none),
|
|
410
|
+
* or null when the question could not be asked at all.
|
|
411
|
+
*
|
|
412
|
+
* For a caller holding several identities at once — a round that imported
|
|
413
|
+
* three logins asks once, not three times, since each ask is a full usage
|
|
414
|
+
* collection that can take a minute on a cold network.
|
|
415
|
+
*/
|
|
416
|
+
async function collectVerdicts({ runner, bin }) {
|
|
377
417
|
try {
|
|
378
418
|
const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
|
|
379
419
|
if (!out?.ok) return null;
|
|
@@ -381,20 +421,47 @@ export async function verdictNow(email, org, { runner = run, bin = cswapBin } =
|
|
|
381
421
|
// The same freshly-read answer feeds the cache the panel draws from, since
|
|
382
422
|
// it cost a subprocess either way.
|
|
383
423
|
const byNum = readVerdicts(out.stdout);
|
|
384
|
-
|
|
424
|
+
const identities = {};
|
|
385
425
|
for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
426
|
+
if (Number.isInteger(a?.number) && typeof a?.email === "string" && a.email.trim()) {
|
|
427
|
+
identities[String(a.number)] = `${a.email.trim().toLowerCase()}@@${a.organizationUuid ?? ""}`;
|
|
428
|
+
}
|
|
389
429
|
}
|
|
390
|
-
|
|
430
|
+
_verdicts = { at: Date.now(), byNum, identities };
|
|
431
|
+
return (Array.isArray(d?.accounts) ? d.accounts : []).map(a => ({
|
|
432
|
+
number: a?.number,
|
|
433
|
+
email: String(a?.email ?? "").trim().toLowerCase(),
|
|
434
|
+
org: a?.organizationUuid ?? "",
|
|
435
|
+
status: typeof a?.usageStatus === "string" ? a.usageStatus : null,
|
|
436
|
+
// For the account Claude Code is signed in as, claude-swap reads the
|
|
437
|
+
// LIVE credential, not the stored copy — so its verdict there is about
|
|
438
|
+
// the live login. See checkImports.
|
|
439
|
+
active: a?.active === true,
|
|
440
|
+
}));
|
|
391
441
|
} catch { return null; }
|
|
392
442
|
}
|
|
393
443
|
|
|
444
|
+
const verdictQueue = createVerdictQueue(() => collectVerdicts({ runner: run, bin: cswapBin }));
|
|
445
|
+
|
|
446
|
+
/** Routine readers share a collection; post-write callers wait for any older
|
|
447
|
+
* collection and start a new one, so they never inspect the pre-write store. */
|
|
448
|
+
export function verdictsNow({ runner = run, bin = cswapBin, fresh = false } = {}) {
|
|
449
|
+
// Tests and callers supplying their own runner must receive their own answer.
|
|
450
|
+
if (runner !== run || bin !== cswapBin) return collectVerdicts({ runner, bin });
|
|
451
|
+
return verdictQueue.ask({ fresh });
|
|
452
|
+
}
|
|
453
|
+
|
|
394
454
|
/** claude-swap's verdict for a slot, or null when there is none fresh enough. */
|
|
395
|
-
function verdictFor(num, now) {
|
|
396
|
-
|
|
397
|
-
|
|
455
|
+
function verdictFor(num, now, email, org) {
|
|
456
|
+
return cachedVerdictFor(_verdicts, num, now, email, org);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Only attach a cached slot verdict to the identity it was collected for. */
|
|
460
|
+
export function cachedVerdictFor(cache, num, now, email, org) {
|
|
461
|
+
if (now - cache.at > VERDICT_TTL_MS) return null;
|
|
462
|
+
const identity = `${String(email ?? "").trim().toLowerCase()}@@${org ?? ""}`;
|
|
463
|
+
if (cache.identities[String(num)] !== identity) return null;
|
|
464
|
+
const v = cache.byNum[String(num)];
|
|
398
465
|
return typeof v === "string" && v !== "" ? v : null;
|
|
399
466
|
}
|
|
400
467
|
|
|
@@ -439,22 +506,12 @@ function nudgeCollector(rows, slots, now, activeNum) {
|
|
|
439
506
|
cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
|
|
440
507
|
return;
|
|
441
508
|
}
|
|
442
|
-
if (
|
|
443
|
-
_verdictInFlight = true;
|
|
509
|
+
if (verdictQueue.busy()) return;
|
|
444
510
|
// Fire-and-forget: this function is deliberately synchronous so callers never
|
|
445
511
|
// wait on it, and resolving the binary is the only async part. `run` rather
|
|
446
512
|
// than `runDetached` only so the output can be read; the caller is no more
|
|
447
513
|
// aware of it than before.
|
|
448
|
-
|
|
449
|
-
.then(bin => run(bin, ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS }))
|
|
450
|
-
.then(out => {
|
|
451
|
-
const byNum = out?.ok ? readVerdicts(out.stdout) : {};
|
|
452
|
-
// Replaced whole rather than merged. A slot that has gone away must not
|
|
453
|
-
// keep the verdict it had when it was last seen.
|
|
454
|
-
if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
|
|
455
|
-
})
|
|
456
|
-
.catch(() => {})
|
|
457
|
-
.finally(() => { _verdictInFlight = false; });
|
|
514
|
+
void verdictsNow();
|
|
458
515
|
}
|
|
459
516
|
|
|
460
517
|
/** Long enough for a cold collection over a slow network, short enough that a
|
|
@@ -637,6 +694,7 @@ async function readRoster(now, gen) {
|
|
|
637
694
|
.filter(Boolean),
|
|
638
695
|
].filter(Boolean);
|
|
639
696
|
|
|
697
|
+
const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
|
|
640
698
|
accounts.push({
|
|
641
699
|
num: Number(num),
|
|
642
700
|
email: acct.email ?? null,
|
|
@@ -654,7 +712,7 @@ async function readRoster(now, gen) {
|
|
|
654
712
|
// `stale-copy` row means the live session is fine while the copy in the
|
|
655
713
|
// store is dead, and the copy is what a share would carry and what a
|
|
656
714
|
// peer's copy would heal. So both kinds of trouble read as not alive.
|
|
657
|
-
alive: trouble == null,
|
|
715
|
+
alive: storedCopyAlive(trouble == null, collector),
|
|
658
716
|
active: String(seq.activeAccountNumber) === num,
|
|
659
717
|
disabled: acct.disabled === true,
|
|
660
718
|
lanes,
|
|
@@ -694,7 +752,7 @@ async function readRoster(now, gen) {
|
|
|
694
752
|
// "no_credentials", "relogin_required", "keychain_unavailable", … It is
|
|
695
753
|
// what turns "not collecting" into a sentence with a next step in it, and
|
|
696
754
|
// it is null on every machine where the collector has not been asked yet.
|
|
697
|
-
collector
|
|
755
|
+
collector,
|
|
698
756
|
});
|
|
699
757
|
}
|
|
700
758
|
|
|
@@ -211,3 +211,38 @@ export function hasClaudeInstalled({
|
|
|
211
211
|
// inside ~/.claude — so the loop above cannot reach it there.
|
|
212
212
|
return exists(path.join(home, ".claude.json"));
|
|
213
213
|
}
|
|
214
|
+
|
|
215
|
+
/** Longest slug CC will store verbatim. Anything longer is truncated here and
|
|
216
|
+
* given a hash suffix, so two deep paths sharing a 200-character prefix still
|
|
217
|
+
* get separate directories. */
|
|
218
|
+
const CC_SLUG_MAX = 200;
|
|
219
|
+
|
|
220
|
+
/** CC's hash of the *unencoded* path, used only for the truncation suffix:
|
|
221
|
+
* the classic h*31 + c string hash, kept in a signed 32-bit int. */
|
|
222
|
+
function ccPathHash(s) {
|
|
223
|
+
let h = 0;
|
|
224
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
225
|
+
return h;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Encode an absolute path the way CC stores it under
|
|
229
|
+
* ~/.claude/projects/<slug>/, so a scan looks in the directory CC actually
|
|
230
|
+
* wrote — index.mjs's auto-memory scan, and the Projects rollup reading a
|
|
231
|
+
* transcript's folder back out of it (account-projects.mjs).
|
|
232
|
+
*
|
|
233
|
+
* CC flattens *every* non-alphanumeric character to "-", not just the path
|
|
234
|
+
* separators and the Windows drive colon this used to replace. Dots are the
|
|
235
|
+
* ones that bite: a worktree under .claude/worktrees/, or any folder named
|
|
236
|
+
* like my.app, produced a slug with a literal dot in it, the auto-memory
|
|
237
|
+
* readdir missed, and the project's auto-memory files were quietly left out
|
|
238
|
+
* of the context panel. Underscores and spaces were wrong the same way. */
|
|
239
|
+
export function ccProjectSlug(cwd) {
|
|
240
|
+
if (!cwd) return "";
|
|
241
|
+
// resolve() gives the platform's own absolute form — "/Users/…" on
|
|
242
|
+
// macOS/Linux, "C:\Users\…" on Windows — and this class covers both, so the
|
|
243
|
+
// drive colon and the backslashes fall out of the general rule.
|
|
244
|
+
const abs = resolve(cwd);
|
|
245
|
+
const slug = abs.replace(/[^a-zA-Z0-9]/g, "-");
|
|
246
|
+
if (slug.length <= CC_SLUG_MAX) return slug;
|
|
247
|
+
return `${slug.slice(0, CC_SLUG_MAX)}-${Math.abs(ccPathHash(abs)).toString(36)}`;
|
|
248
|
+
}
|