ccdeck 3.26.5 → 3.27.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/dist/web/assets/{BrowserWatchModal-B6MuDGvq.js → BrowserWatchModal-Ce0hVjif.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BJA3mUzs.js → UsageHistoryModal-Cf6oqnB8.js} +1 -1
- package/dist/web/assets/{index-DlaHgzye.css → index-B5RadDLY.css} +1 -1
- package/dist/web/assets/index-BrjE0Aat.js +297 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/account-projects.mjs +410 -0
- package/src/server/cswap-auto.mjs +9 -0
- package/src/server/index.mjs +54 -0
- package/src/server/swap-log.mjs +188 -0
- package/dist/web/assets/index-D4fPrztm.js +0 -297
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The timeline of which account was active, and when.
|
|
2
|
+
//
|
|
3
|
+
// Nothing in claude-swap or the deck ever recorded this. sequence.json holds a
|
|
4
|
+
// single `activeAccountNumber` — who is active *now* — and overwrites it on
|
|
5
|
+
// every switch, so the moment a swap happens the previous truth is gone. The
|
|
6
|
+
// account-projects report needs the opposite: a durable, append-only record of
|
|
7
|
+
// "at time T the active account became X", so a transcript message written at
|
|
8
|
+
// some past instant can be attributed to whoever was active *then*.
|
|
9
|
+
//
|
|
10
|
+
// The deck is the one thing that performs a swap (it drives `cswap switch` and
|
|
11
|
+
// the auto tick), so it does not have to reconstruct or guess: it writes one
|
|
12
|
+
// line each time it moves the live account. The past before this file existed
|
|
13
|
+
// is unrecoverable — there is no timestamped history anywhere to mine — so the
|
|
14
|
+
// report is honest only from the first line here onward, and everything earlier
|
|
15
|
+
// is left unattributed.
|
|
16
|
+
//
|
|
17
|
+
// One line per swap, JSON:
|
|
18
|
+
// { "at": <ms>, "slot": <n>, "email": "...", "orgUuid": "...", "source": "manual"|"auto"|"start" }
|
|
19
|
+
// `source` "start" is the anchor the rollup writes once at boot for whoever is
|
|
20
|
+
// already active, so a machine that never swaps still attributes its work.
|
|
21
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { backupRoot } from "./claude-accounts.mjs";
|
|
25
|
+
|
|
26
|
+
/** The log the deck appends swaps to. Under ~/.agents-deck like cswap-auto's
|
|
27
|
+
* own state, not under the claude-swap store — this is the deck's record, not
|
|
28
|
+
* claude-swap's. */
|
|
29
|
+
export function swapLogPath(home = homedir()) {
|
|
30
|
+
return join(home, ".agents-deck", "log", "account-active.jsonl");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read one JSON value from a file, or null if it is missing or unparseable. */
|
|
34
|
+
async function readJsonSafe(path) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `(email, organizationUuid)` a slot number points at right now, read from
|
|
43
|
+
* claude-swap's sequence.json. Null when the store or the slot is not there. */
|
|
44
|
+
export async function identityForSlot(slot, root = backupRoot()) {
|
|
45
|
+
const seq = await readJsonSafe(join(root, "sequence.json"));
|
|
46
|
+
const acc = seq?.accounts?.[String(slot)];
|
|
47
|
+
if (!acc) return null;
|
|
48
|
+
return {
|
|
49
|
+
email: typeof acc.email === "string" ? acc.email : "",
|
|
50
|
+
orgUuid: typeof acc.organizationUuid === "string" ? acc.organizationUuid : "",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The slot claude-swap says is active right now, or null. */
|
|
55
|
+
export async function currentActiveSlot(root = backupRoot()) {
|
|
56
|
+
const seq = await readJsonSafe(join(root, "sequence.json"));
|
|
57
|
+
const n = Number(seq?.activeAccountNumber);
|
|
58
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse the log into swap entries sorted oldest-first. A truncated or garbage
|
|
62
|
+
* last line (a crash mid-append) is skipped, never fatal. */
|
|
63
|
+
export async function readSwapLog(path = swapLogPath()) {
|
|
64
|
+
const text = await readFile(path, "utf8").catch(() => "");
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const line of text.split("\n")) {
|
|
67
|
+
if (!line) continue;
|
|
68
|
+
let obj = null;
|
|
69
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
70
|
+
if (!obj || typeof obj !== "object") continue;
|
|
71
|
+
const at = Number(obj.at);
|
|
72
|
+
if (!Number.isFinite(at)) continue;
|
|
73
|
+
out.push({
|
|
74
|
+
at,
|
|
75
|
+
slot: Number(obj.slot) || 0,
|
|
76
|
+
email: typeof obj.email === "string" ? obj.email : "",
|
|
77
|
+
orgUuid: typeof obj.orgUuid === "string" ? obj.orgUuid : "",
|
|
78
|
+
source: typeof obj.source === "string" ? obj.source : "",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
out.sort((a, b) => a.at - b.at);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Append one swap line. Creates the log directory on first use. Best-effort:
|
|
86
|
+
* a failure to record must never break a switch, so callers ignore rejection. */
|
|
87
|
+
export async function appendSwap(entry, path = swapLogPath()) {
|
|
88
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
89
|
+
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Record that `slot` became the active account, resolving its identity from the
|
|
94
|
+
* store. `source` says which path moved it. Returns the entry written, or null
|
|
95
|
+
* when the slot has no identity (nothing worth attributing to).
|
|
96
|
+
*
|
|
97
|
+
* Best-effort by contract: every caller sits on a hot path (a click, an auto
|
|
98
|
+
* tick) that must not fail because a log write did. Errors are swallowed here
|
|
99
|
+
* so callers can `void recordSwap(...)` without a catch.
|
|
100
|
+
*/
|
|
101
|
+
export async function recordSwap(slot, source, { now = Date.now, root, path } = {}) {
|
|
102
|
+
try {
|
|
103
|
+
const store = root ?? backupRoot();
|
|
104
|
+
// Trust the store's own idea of who is active over a caller-supplied slot
|
|
105
|
+
// when that slot is missing or malformed — after a switch, sequence.json's
|
|
106
|
+
// activeAccountNumber is the authoritative new slot, so the auto tick can
|
|
107
|
+
// record without threading the number through cswap's JSON.
|
|
108
|
+
let n = Number(slot);
|
|
109
|
+
if (!Number.isInteger(n) || n <= 0) n = await currentActiveSlot(store);
|
|
110
|
+
if (!n) return null;
|
|
111
|
+
const id = await identityForSlot(n, store);
|
|
112
|
+
if (!id) return null;
|
|
113
|
+
const entry = { at: now(), slot: n, email: id.email, orgUuid: id.orgUuid, source };
|
|
114
|
+
await appendSwap(entry, path ?? swapLogPath());
|
|
115
|
+
return entry;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Seed an anchor for the currently-active account, once, if the log does not
|
|
123
|
+
* already end on that account. Called at boot so a deck that never swaps still
|
|
124
|
+
* attributes its work to the account it launched on, and so the very first
|
|
125
|
+
* upgrade writes a "tracked since" point.
|
|
126
|
+
*
|
|
127
|
+
* Deduped against the last entry's `(email, orgUuid)`: a restart on the same
|
|
128
|
+
* account adds nothing, but a manual switch made while the deck was down (so no
|
|
129
|
+
* line was written for it) is still captured here on the next boot.
|
|
130
|
+
*/
|
|
131
|
+
export async function seedActive({ now = Date.now, root, path } = {}) {
|
|
132
|
+
try {
|
|
133
|
+
const logPath = path ?? swapLogPath();
|
|
134
|
+
const slot = await currentActiveSlot(root ?? backupRoot());
|
|
135
|
+
if (!slot) return null;
|
|
136
|
+
const id = await identityForSlot(slot, root ?? backupRoot());
|
|
137
|
+
if (!id) return null;
|
|
138
|
+
const entries = await readSwapLog(logPath);
|
|
139
|
+
const last = entries[entries.length - 1];
|
|
140
|
+
if (last && last.email === id.email && last.orgUuid === id.orgUuid) return null;
|
|
141
|
+
const entry = { at: now(), slot, email: id.email, orgUuid: id.orgUuid, source: "start" };
|
|
142
|
+
await appendSwap(entry, logPath);
|
|
143
|
+
return entry;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Which account was active at millisecond `ts`: the last entry at or before it.
|
|
151
|
+
* Null when `ts` precedes every entry — before tracking began — OR when the
|
|
152
|
+
* governing entry is a `stop` marker, which the rollup writes to fence off the
|
|
153
|
+
* time the deck was not running: nothing recorded who was active then, so work
|
|
154
|
+
* with a timestamp inside a stop→start gap is honestly unattributable rather
|
|
155
|
+
* than charged to whichever account happened to be active when the deck closed.
|
|
156
|
+
* `entries` must be sorted oldest-first, as `readSwapLog` returns them.
|
|
157
|
+
*/
|
|
158
|
+
export function accountAtTime(entries, ts) {
|
|
159
|
+
if (!Array.isArray(entries) || !entries.length) return null;
|
|
160
|
+
if (ts < entries[0].at) return null;
|
|
161
|
+
// Binary search for the rightmost entry with at <= ts.
|
|
162
|
+
let lo = 0, hi = entries.length - 1, ans = 0;
|
|
163
|
+
while (lo <= hi) {
|
|
164
|
+
const mid = (lo + hi) >> 1;
|
|
165
|
+
if (entries[mid].at <= ts) { ans = mid; lo = mid + 1; }
|
|
166
|
+
else hi = mid - 1;
|
|
167
|
+
}
|
|
168
|
+
const e = entries[ans];
|
|
169
|
+
if (e.source === "stop") return null; // inside a gap the deck was not running
|
|
170
|
+
return { email: e.email, orgUuid: e.orgUuid, slot: e.slot };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Fence off a stretch of time as "the deck was not running" from `at`, so
|
|
174
|
+
* work timestamped after it is unattributed until the next real entry. */
|
|
175
|
+
export async function markGap(at, path = swapLogPath()) {
|
|
176
|
+
try {
|
|
177
|
+
await appendSwap({ at, slot: 0, email: "", orgUuid: "", source: "stop" }, path);
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The earliest instant the log covers — what the report shows as "tracked
|
|
185
|
+
* since". Null on an empty log. */
|
|
186
|
+
export function trackedSince(entries) {
|
|
187
|
+
return Array.isArray(entries) && entries.length ? entries[0].at : null;
|
|
188
|
+
}
|