ccdeck 3.22.14 → 3.23.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 +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +252 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -182
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +385 -51
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +495 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BdYsAtN8.js +0 -274
- package/dist/web/assets/index-CgwmcBHv.css +0 -1
|
@@ -130,8 +130,25 @@ ${vars}
|
|
|
130
130
|
*/
|
|
131
131
|
export function unitFor({ execPath, script, logPath, args = [], env = {}, product = "ccdeck" } = {}) {
|
|
132
132
|
const cmd = [execPath, script, ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
|
|
133
|
+
// QUOTED, AND `%` ESCAPED. systemd.exec(5) splits an Environment= assignment
|
|
134
|
+
// on whitespace unless the whole thing is double-quoted, and it expands `%`
|
|
135
|
+
// specifiers in the value. ExecStart above quotes its own arguments; this
|
|
136
|
+
// line did neither, so a scope directory with a space in it was truncated at
|
|
137
|
+
// the space. Handed to systemd itself:
|
|
138
|
+
//
|
|
139
|
+
// $ systemd-analyze verify ccdeck.service
|
|
140
|
+
// ccdeck.service:6: Invalid environment assignment, ignoring: Configs/.claude
|
|
141
|
+
//
|
|
142
|
+
// from `Environment=CLAUDE_CONFIG_DIR=/home/ana/My Configs/.claude`. The deck
|
|
143
|
+
// then received `/home/ana/My` — neither the shell's value nor the default,
|
|
144
|
+
// which is the two-registries outcome scopeEnv's own header describes: "two
|
|
145
|
+
// decks and two LAN keys on one machine". `%b` is the boot-ID specifier, so
|
|
146
|
+
// `/home/ana/100%backup` was rewritten just as quietly.
|
|
147
|
+
//
|
|
148
|
+
// The macOS branch was always safe: plistFor puts each value in its own
|
|
149
|
+
// <string> and XML-escapes it. Windows carries none of them.
|
|
133
150
|
const vars = Object.entries({ AGENTS_DECK_DETACHED: "1", ...env })
|
|
134
|
-
.map(([k, v]) => `Environment
|
|
151
|
+
.map(([k, v]) => `Environment="${k}=${String(v).replace(/%/g, "%%")}"`).join("\n");
|
|
135
152
|
return `[Unit]
|
|
136
153
|
Description=${product} — live deck of Claude Code + Codex agents
|
|
137
154
|
After=default.target
|
|
@@ -359,9 +376,19 @@ export function installService({
|
|
|
359
376
|
// shell that installed it: launchd hands the job a minimal environment,
|
|
360
377
|
// systemd --user the same. That is right in production — the deck should see
|
|
361
378
|
// the user's real home — and it is exactly what makes a login item impossible
|
|
362
|
-
// to test in a sandbox without this parameter.
|
|
363
|
-
//
|
|
364
|
-
|
|
379
|
+
// to test in a sandbox without this parameter.
|
|
380
|
+
//
|
|
381
|
+
// DEFAULTED, NOT LEFT TO EACH CALLER TO REMEMBER. The scope directories
|
|
382
|
+
// belong in every login item this deck writes, and two of the three callers
|
|
383
|
+
// did not pass them: `--install` and `--install-service` wrote an item keyed
|
|
384
|
+
// to the default registries while the shell that typed them was keyed to
|
|
385
|
+
// CLAUDE_CONFIG_DIR — the two-decks-on-one-machine split scopeEnv's own
|
|
386
|
+
// header describes, arrived at from the two entry points whose whole purpose
|
|
387
|
+
// is to install. Only the first-start offer carried them. A caller that
|
|
388
|
+
// genuinely wants a different job environment — the sandboxed end-to-end
|
|
389
|
+
// test, which passes HOME and the deck's own directories — still says so,
|
|
390
|
+
// and replaces this outright rather than adding to it.
|
|
391
|
+
serviceEnv = scopeEnv(env),
|
|
365
392
|
product = "ccdeck",
|
|
366
393
|
fs = { mkdirSync, writeFileSync },
|
|
367
394
|
run = spawnSync,
|
package/src/server/open-url.mjs
CHANGED
|
@@ -98,8 +98,28 @@ export function launchers(url, { platform = process.platform, env = process.env,
|
|
|
98
98
|
// not wait for openUrl.
|
|
99
99
|
const tries = [{
|
|
100
100
|
file: "powershell.exe",
|
|
101
|
-
//
|
|
102
|
-
//
|
|
101
|
+
// THIS IS NOT A SEPARATE ARGV CHANNEL, and the comment that used to sit
|
|
102
|
+
// here said it was: "the URL as its own argv entry rather than inside a
|
|
103
|
+
// script string, where `;` and `&` are PowerShell's own operators."
|
|
104
|
+
// There is no such entry. `powershell.exe -Command` documents that the
|
|
105
|
+
// value must be the LAST parameter, *"because any characters typed after
|
|
106
|
+
// the command are interpreted as the command parameters"* — everything
|
|
107
|
+
// after it is appended to the command TEXT and parsed by PowerShell, so
|
|
108
|
+
// `Start-Process` and the URL arrive as one script line whatever argv
|
|
109
|
+
// they were handed in as.
|
|
110
|
+
//
|
|
111
|
+
// The repo has already discovered this one module over. browser-react.mjs
|
|
112
|
+
// records it verbatim for the toast: the same shape, the same wrong
|
|
113
|
+
// assumption, and a notification that "never appeared on Windows, for the
|
|
114
|
+
// reaction that is the default".
|
|
115
|
+
//
|
|
116
|
+
// Nothing is broken today, which is why this is a comment repair rather
|
|
117
|
+
// than a code one: the only caller passes a loopback URL this deck built
|
|
118
|
+
// itself, and the `cmd /c start` fallback below would catch a parse
|
|
119
|
+
// failure anyway. What was wrong is a file whose stated job is to encode
|
|
120
|
+
// platform knowledge recording a safety property it does not have — and
|
|
121
|
+
// the next caller to pass a URL with a query string in it would have
|
|
122
|
+
// trusted that sentence.
|
|
103
123
|
args: ["-NoProfile", "-NonInteractive", "-Command", "Start-Process", url],
|
|
104
124
|
}];
|
|
105
125
|
const viaCmd = startCommand(url, env);
|
|
@@ -120,8 +120,11 @@ export function blocksIn(chunk) {
|
|
|
120
120
|
export function createOutputWatch(io = {}) {
|
|
121
121
|
const statFile = io.stat ?? (p => stat(p));
|
|
122
122
|
const openFile = io.open ?? (p => open(p, "r"));
|
|
123
|
-
/** sid -> { path, offset
|
|
124
|
-
*
|
|
123
|
+
/** sid -> { path, offset } — where this session's file is and how far into it
|
|
124
|
+
* the watch has already looked. No size beside them: every decision in
|
|
125
|
+
* `pollOnce` is made from the size its own `stat` just returned, and the one
|
|
126
|
+
* that used to be remembered here was written four times and read by
|
|
127
|
+
* nothing (#1089). */
|
|
125
128
|
const seen = new Map();
|
|
126
129
|
|
|
127
130
|
/** Remember where a session's transcript is. Called for every hook payload
|
|
@@ -135,7 +138,7 @@ export function createOutputWatch(io = {}) {
|
|
|
135
138
|
// Start at the END of a file the watch has not seen before. Everything
|
|
136
139
|
// already in it happened before the deck looked, and replaying it would
|
|
137
140
|
// report a morning's thinking as having just occurred.
|
|
138
|
-
seen.set(sid, { path, offset: null
|
|
141
|
+
seen.set(sid, { path, offset: null });
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
function forget(sid) { seen.delete(sid); }
|
|
@@ -152,8 +155,15 @@ export function createOutputWatch(io = {}) {
|
|
|
152
155
|
* Sessions the caller believes are live, and no others: a finished session's
|
|
153
156
|
* file can still be appended to by a later turn, and the deck has its own
|
|
154
157
|
* rules about what counts as live. This asks no questions about that.
|
|
158
|
+
*
|
|
159
|
+
* `onLines`, when given, is handed each session's new whole lines as one
|
|
160
|
+
* string, with the file they came from. The watch is the only reader that
|
|
161
|
+
* looks at a transcript while a session is resting, and resting is exactly
|
|
162
|
+
* when Claude Code writes its recap (see session-recap.mjs). It is a tap, not
|
|
163
|
+
* a filter: nothing it does changes the blocks returned, and a throw inside
|
|
164
|
+
* it stops at the tap rather than at the watch.
|
|
155
165
|
*/
|
|
156
|
-
async function
|
|
166
|
+
async function pollOnce(liveSids, onLines) {
|
|
157
167
|
const out = [];
|
|
158
168
|
for (const sid of liveSids) {
|
|
159
169
|
const entry = seen.get(sid);
|
|
@@ -163,15 +173,15 @@ export function createOutputWatch(io = {}) {
|
|
|
163
173
|
const size = Number(st?.size ?? 0);
|
|
164
174
|
// First sight: record where the end is and report nothing. The file's
|
|
165
175
|
// whole history is older than this deck's interest in it.
|
|
166
|
-
if (entry.offset == null) { entry.offset = size;
|
|
176
|
+
if (entry.offset == null) { entry.offset = size; continue; }
|
|
167
177
|
// TRUNCATED OR REPLACED. A file that is now shorter than the byte this
|
|
168
178
|
// watch was going to read from is not the file it was reading; seeking to
|
|
169
179
|
// the old offset would return whatever now lives there. Start again at
|
|
170
180
|
// the new end rather than guessing which bytes are which.
|
|
171
|
-
if (size < entry.offset) { entry.offset = size;
|
|
181
|
+
if (size < entry.offset) { entry.offset = size; continue; }
|
|
172
182
|
if (size === entry.offset) continue;
|
|
173
183
|
// Past the ceiling, skip to the end. See MAX_TAIL_BYTES.
|
|
174
|
-
if (size - entry.offset > MAX_TAIL_BYTES) { entry.offset = size;
|
|
184
|
+
if (size - entry.offset > MAX_TAIL_BYTES) { entry.offset = size; continue; }
|
|
175
185
|
|
|
176
186
|
const from = entry.offset;
|
|
177
187
|
const want = size - from;
|
|
@@ -187,7 +197,6 @@ export function createOutputWatch(io = {}) {
|
|
|
187
197
|
const dec = new StringDecoder("utf8");
|
|
188
198
|
text = dec.write(buf.subarray(0, Math.max(0, bytesRead))) + dec.end();
|
|
189
199
|
entry.offset = from + Math.max(0, bytesRead);
|
|
190
|
-
entry.size = size;
|
|
191
200
|
} catch {
|
|
192
201
|
continue;
|
|
193
202
|
} finally {
|
|
@@ -214,9 +223,51 @@ export function createOutputWatch(io = {}) {
|
|
|
214
223
|
// Oldest first, so a reader that refuses to move backwards accepts the
|
|
215
224
|
// whole run rather than only its last member.
|
|
216
225
|
for (const b of blocksIn(text)) out.push({ sid, kind: b.kind, at: b.at });
|
|
226
|
+
if (onLines) {
|
|
227
|
+
try { onLines(sid, text, entry.path); } catch { /* the tap's failure, not the watch's */ }
|
|
228
|
+
}
|
|
217
229
|
}
|
|
218
230
|
return out;
|
|
219
231
|
}
|
|
220
232
|
|
|
233
|
+
/** Whether a call is inside `pollOnce` right now. */
|
|
234
|
+
let polling = false;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* `pollOnce`, one call at a time (#1089).
|
|
238
|
+
*
|
|
239
|
+
* A session's offset is read at the top of `pollOnce` and written back only
|
|
240
|
+
* after the `stat`, the `open` and the `read` it waits on, so a second call
|
|
241
|
+
* that enters while the first is inside any of them reads the same offset,
|
|
242
|
+
* reads the same bytes and answers with the same blocks — and the server
|
|
243
|
+
* pushes an OutputObserved for each copy. Measured through this module's own
|
|
244
|
+
* `io` seam, with the interleave exact rather than timed: one block appended,
|
|
245
|
+
* two overlapping ticks, two events. They are persisted, so the double count
|
|
246
|
+
* lands in events.jsonl and is drawn onto the activity chart again after
|
|
247
|
+
* every restart. The server ticks this off a 1500ms setInterval, which does
|
|
248
|
+
* not wait for the last tick to finish, and a tick that outruns 1500ms needs
|
|
249
|
+
* nothing more unusual than several live sessions on a slow or
|
|
250
|
+
* network-backed transcript directory.
|
|
251
|
+
*
|
|
252
|
+
* Its two siblings in index.mjs already refused re-entry: codexScanOnce with
|
|
253
|
+
* a boolean, scanTranscript by handing a second caller the read already in
|
|
254
|
+
* flight. The second shape is wrong here — a caller handed the same answer
|
|
255
|
+
* would push the same blocks again — so this is the boolean. An overlapping
|
|
256
|
+
* call answers with nothing and the call already inside answers for both.
|
|
257
|
+
* Nothing is lost by it: whatever lands meanwhile is past the offset the
|
|
258
|
+
* first call writes back, and the next tick reads it. It sits here rather
|
|
259
|
+
* than in outputWatchOnce because this is where the offset lives, and where
|
|
260
|
+
* the suite can drive the overlap without a clock.
|
|
261
|
+
*/
|
|
262
|
+
async function poll(liveSids, onLines) {
|
|
263
|
+
if (polling) return [];
|
|
264
|
+
polling = true;
|
|
265
|
+
try {
|
|
266
|
+
return await pollOnce(liveSids, onLines);
|
|
267
|
+
} finally {
|
|
268
|
+
polling = false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
221
272
|
return { note, forget, clear, size, poll };
|
|
222
273
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The one thing an uninstall leaves behind that is not data: this deck's LAN
|
|
2
|
+
// private key.
|
|
3
|
+
//
|
|
4
|
+
// `--uninstall` is deliberately narrow — it takes the hook entries out and says
|
|
5
|
+
// so, because the event log and the port registry are things somebody may still
|
|
6
|
+
// want. That narrowness has bent once already, for the login item, on the
|
|
7
|
+
// argument that a machine which keeps starting a deck whose hooks were just
|
|
8
|
+
// removed is not data either. The key is the second case and a sharper one.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IS ACTUALLY LEFT. `prefs.json` is the one file the deck writes with a
|
|
11
|
+
// secret in it — deck-home.mjs says so where it names the mode: "0600 on the
|
|
12
|
+
// preferences is load-bearing: it is the one file with a private key in it".
|
|
13
|
+
// That key is this deck's LAN identity: every machine paired with it has pinned
|
|
14
|
+
// the key, and it is what decrypts what crosses between them. An uninstall that
|
|
15
|
+
// reports success and leaves it on the disk has left a credential behind.
|
|
16
|
+
//
|
|
17
|
+
// AND IT IS LEFT IN TWO PLACES, WHICH IS THE PART NOBODY COULD HAVE GUESSED.
|
|
18
|
+
// `migrateDeckFiles` moved prefs.json from `~/.claude/agent-dag` to the
|
|
19
|
+
// platform's data directory by COPYING it and leaving the original exactly
|
|
20
|
+
// where it was — deliberately, so a downgrade finds what it had. So an upgraded
|
|
21
|
+
// machine has the key twice, and the second copy sits in a directory the deck's
|
|
22
|
+
// own documentation describes as holding "the forwarder and the discovery
|
|
23
|
+
// directory". Deleting one of the two is not deleting the key.
|
|
24
|
+
//
|
|
25
|
+
// SO THIS MODULE ANSWERS TWO QUESTIONS AND NOTHING ELSE: where, on THIS machine,
|
|
26
|
+
// is there still a prefs.json that could hold a key, and — when somebody asks
|
|
27
|
+
// for it — take those files off the disk. It does not rewrite prefs.json to
|
|
28
|
+
// strip the field: a partial rewrite of the file that holds the pairings is a
|
|
29
|
+
// worse thing to get wrong than an unlink, and after an uninstall there is
|
|
30
|
+
// nothing left the rest of the file is for.
|
|
31
|
+
import { readFile, rm } from "node:fs/promises";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { deckDataDir, legacyDeckDir } from "./deck-home.mjs";
|
|
35
|
+
|
|
36
|
+
/** The file. Named once, because it is spelled in deck-prefs.mjs too and the
|
|
37
|
+
* two must not drift — see `prefsPath`, which is the same join. */
|
|
38
|
+
export const KEY_FILE = "prefs.json";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every directory on this machine that may still hold one.
|
|
42
|
+
*
|
|
43
|
+
* Deduplicated, and that is not tidiness: on a machine with CLAUDE_CONFIG_DIR
|
|
44
|
+
* set, `deckDataDir` deliberately answers `legacyDeckDir` — those people keep
|
|
45
|
+
* one directory rather than being moved — so the two are the SAME path and
|
|
46
|
+
* reporting it twice would tell somebody there are two copies of their key when
|
|
47
|
+
* there is one.
|
|
48
|
+
*/
|
|
49
|
+
export function keyDirs({ env = process.env, home = homedir(), platform = process.platform } = {}) {
|
|
50
|
+
return [...new Set([deckDataDir(platform, env, home), legacyDeckDir(env, home, platform)])];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What is really on disk, per directory, and whether a key is in it.
|
|
55
|
+
*
|
|
56
|
+
* `holds` is three-valued on purpose:
|
|
57
|
+
*
|
|
58
|
+
* "key" parsed, and `lan.secret` is a non-empty string. This is the file
|
|
59
|
+
* the whole module is about.
|
|
60
|
+
* "no-key" parsed, and there is no secret in it. A deck that never turned
|
|
61
|
+
* LAN sync on, or one whose file predates it. Reported so a caller
|
|
62
|
+
* can offer to remove it without claiming a key is in there —
|
|
63
|
+
* telling somebody their private key is on the machine when it is
|
|
64
|
+
* not is the same defect as the silence, pointing the other way.
|
|
65
|
+
* "unknown" there is a file and this could not read it as JSON: a BOM from
|
|
66
|
+
* Notepad, a half-written file, a permission. UNKNOWN IS NOT
|
|
67
|
+
* "NO": a file that cannot be parsed is exactly the one nobody can
|
|
68
|
+
* rule a key out of, so it is offered for removal like a "key".
|
|
69
|
+
*
|
|
70
|
+
* A file that is not there at all is not in the list. ENOENT is the ordinary
|
|
71
|
+
* answer on a machine where the deck never ran, and an uninstall that announces
|
|
72
|
+
* the absence of a file is noise at the moment somebody least wants it.
|
|
73
|
+
*/
|
|
74
|
+
export async function findKeyFiles(dirs = keyDirs(), deps = {}) {
|
|
75
|
+
const read = deps.readFile ?? readFile;
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const dir of dirs) {
|
|
78
|
+
const path = join(dir, KEY_FILE);
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = await read(path, "utf8");
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Anything but "no such file" means there IS something there and this
|
|
84
|
+
// could not see into it, which is the "unknown" case with the read having
|
|
85
|
+
// failed rather than the parse.
|
|
86
|
+
if (err?.code === "ENOENT") continue;
|
|
87
|
+
out.push({ path, holds: "unknown", why: err?.message ?? String(err) });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
// The BOM, stripped the way installer.mjs strips it off settings.json:
|
|
92
|
+
// `JSON.parse` throws on one and a file Notepad saved is not damaged.
|
|
93
|
+
// Spelled out rather than imported because installer.mjs's `stripBom` is
|
|
94
|
+
// private to that module today — #1056 exports it, and this call should
|
|
95
|
+
// become that import once it lands, so the rule has one spelling.
|
|
96
|
+
const text = String(raw);
|
|
97
|
+
const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
98
|
+
const secret = parsed?.lan?.secret;
|
|
99
|
+
out.push({ path, holds: typeof secret === "string" && secret !== "" ? "key" : "no-key", why: "" });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
out.push({ path, holds: "unknown", why: err?.message ?? String(err) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Take those files off the disk, and never let one failure hide another.
|
|
109
|
+
*
|
|
110
|
+
* `force: true` so a file that vanished between the listing and the unlink — a
|
|
111
|
+
* second deck, a person deleting it by hand in the other window — is a removal
|
|
112
|
+
* that succeeded rather than an error somebody has to interpret. `maxRetries`
|
|
113
|
+
* is the Windows rule the rest of this repo already follows: a scanner or the
|
|
114
|
+
* indexer holds a handle for a moment after the file is touched, and the first
|
|
115
|
+
* unlink answers EBUSY. rm-temp-dir.ts paid for that knowledge twice.
|
|
116
|
+
*
|
|
117
|
+
* Returns both halves rather than throwing on the first failure, because an
|
|
118
|
+
* uninstall that removed one of two copies and stopped has to say WHICH — the
|
|
119
|
+
* caller's exit code and the user's next move both depend on it.
|
|
120
|
+
*/
|
|
121
|
+
export async function purgeKeyFiles(files, deps = {}) {
|
|
122
|
+
const remove = deps.rm ?? rm;
|
|
123
|
+
const removed = [];
|
|
124
|
+
const failed = [];
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const path = typeof file === "string" ? file : file.path;
|
|
127
|
+
try {
|
|
128
|
+
await remove(path, { force: true, maxRetries: 5, retryDelay: 20 });
|
|
129
|
+
removed.push(path);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
failed.push({ path, why: err?.message ?? String(err) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { removed, failed };
|
|
135
|
+
}
|
package/src/server/quota.mjs
CHANGED
|
@@ -266,6 +266,35 @@ const FORCE_POLL_MS = 60_000;
|
|
|
266
266
|
// collector is backing off or not running — the case self-polling exists for.
|
|
267
267
|
const STORE_TRUSTED_MS = 45 * 60_000;
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* How long a caller may be kept waiting before this module answers anyway.
|
|
271
|
+
*
|
|
272
|
+
* Source 3 has no ceiling of its own and never had one. Its cost is the sum of
|
|
273
|
+
* every step below it — three spawns under a 15-second deadline each, with two
|
|
274
|
+
* 1.2-second sleeps between them — and nobody had ever added it up. Measured on
|
|
275
|
+
* a sandboxed deck with a `claude` that never prints (#1011):
|
|
276
|
+
*
|
|
277
|
+
* $ curl -s -m 120 -w "[%{http_code}] time_total=%{time_total}s" .../api/quota
|
|
278
|
+
* {"ok":false,"reason":"cli_failed",...}
|
|
279
|
+
* [200] time_total=47.411343s
|
|
280
|
+
*
|
|
281
|
+
* 3 × 15s + 2 × 1.2s = 47.4, to the tenth. The rest of the deck stayed
|
|
282
|
+
* responsive throughout — /api/health answered in 0.0008s while that request
|
|
283
|
+
* was out — so what this bounds is one pinned request rather than a stalled
|
|
284
|
+
* server: a panel on "Checking…" for three quarters of a minute, and one fewer
|
|
285
|
+
* socket in the browser's per-origin pool for as long as it is out.
|
|
286
|
+
*
|
|
287
|
+
* FIVE SECONDS, and it is a budget rather than a guess at how long the CLI
|
|
288
|
+
* takes. A sandbox with a real `claude` and no credentials answered in 4.2s and
|
|
289
|
+
* 4.5s, which is the slow END of the good case, and the good case is already
|
|
290
|
+
* served from the store or the API in milliseconds. What is left above five
|
|
291
|
+
* seconds is the CLI in trouble, and for that the honest answer is "not yet".
|
|
292
|
+
*
|
|
293
|
+
* Nothing is cancelled when it expires — see answerWithin. The read runs on,
|
|
294
|
+
* publishes to the cache, and the panel's next poll gets the real numbers.
|
|
295
|
+
*/
|
|
296
|
+
export const QUOTA_DEADLINE_MS = 5_000;
|
|
297
|
+
|
|
269
298
|
/**
|
|
270
299
|
* claude-swap's row for the active account, in the shape the panel speaks.
|
|
271
300
|
*
|
|
@@ -477,14 +506,25 @@ export function quotaClaudeBin(platform = process.platform, env = process.env,
|
|
|
477
506
|
return "claude";
|
|
478
507
|
}
|
|
479
508
|
|
|
480
|
-
|
|
509
|
+
/**
|
|
510
|
+
* @param deadlineMs how long the CALLER is prepared to wait. Zero — the
|
|
511
|
+
* default, and what every internal caller uses — waits for the read however
|
|
512
|
+
* long it takes, which is what a test driving the chain end to end wants.
|
|
513
|
+
* The route passes QUOTA_DEADLINE_MS; see answerWithin for what expiring
|
|
514
|
+
* means, which is not cancelling.
|
|
515
|
+
*/
|
|
516
|
+
export async function fetchClaudeQuota({ force = false, deadlineMs = 0 } = {}) {
|
|
481
517
|
const now = Date.now();
|
|
482
518
|
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
483
519
|
|
|
484
520
|
// If another CLI probe is already in flight, wait for it instead of spawning a
|
|
485
521
|
// second concurrent process (which can return empty output and overwrite the
|
|
486
522
|
// good result with 0%).
|
|
487
|
-
|
|
523
|
+
//
|
|
524
|
+
// Under a deadline the joiner is bounded too, and has to be: joining a read
|
|
525
|
+
// that started 46 seconds ago is the same 47-second wait reached by the other
|
|
526
|
+
// door, and it is the door the panel's own 60-second poll walks through.
|
|
527
|
+
if (_inflight) return answerWithin(_inflight, deadlineMs, now);
|
|
488
528
|
|
|
489
529
|
// `_inflight === mine` rather than a bare clear: invalidateQuotaCache drops
|
|
490
530
|
// `_inflight` so the next caller starts a read that knows the account moved,
|
|
@@ -495,7 +535,51 @@ export async function fetchClaudeQuota({ force = false } = {}) {
|
|
|
495
535
|
const mine = _doFetch(now, force, _generation)
|
|
496
536
|
.finally(() => { if (_inflight === mine) _inflight = null; });
|
|
497
537
|
_inflight = mine;
|
|
498
|
-
return mine;
|
|
538
|
+
return answerWithin(mine, deadlineMs, now);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The read's answer, or the best thing we can say by the time the caller's
|
|
543
|
+
* patience runs out.
|
|
544
|
+
*
|
|
545
|
+
* THE READ IS NOT CANCELLED, and that is the whole design rather than a
|
|
546
|
+
* shortcut. It keeps running, keeps `_inflight` filled so nothing spawns a
|
|
547
|
+
* second Claude Code beside it, and ends in publish() like any other read — so
|
|
548
|
+
* the numbers it eventually produces are in the cache for whoever asks next.
|
|
549
|
+
* The panel polls every 60 seconds and presses ↻ into the same slot, so "not
|
|
550
|
+
* yet" is a state it leaves on its own within a poll. Cancelling would spend a
|
|
551
|
+
* whole `claude --print /usage` and throw the result away, and the budget this
|
|
552
|
+
* module is built around (28-30 requests an hour, shared with claude-swap) is
|
|
553
|
+
* the one thing it must not do.
|
|
554
|
+
*
|
|
555
|
+
* WHAT "NOT YET" SAYS. The freshest real reading this module holds, marked
|
|
556
|
+
* stale — the same answer, in the same shape, that the poll-floor branch of
|
|
557
|
+
* _doFetch already gives for the same question — and only when there is none,
|
|
558
|
+
* the `waiting` reason the panel has rendered a sentence for all along. Its
|
|
559
|
+
* timestamp is the reading's own and never `now`: an age indicator that
|
|
560
|
+
* vouches for numbers collected hours ago is what quota-held-age.test.ts
|
|
561
|
+
* exists to stop, and a deadline is not a licence to re-stamp them.
|
|
562
|
+
*
|
|
563
|
+
* Exported because a deadline nothing can point at is a deadline nobody can
|
|
564
|
+
* test.
|
|
565
|
+
*/
|
|
566
|
+
export function answerWithin(read, deadlineMs, now = Date.now()) {
|
|
567
|
+
if (!(deadlineMs > 0)) return read;
|
|
568
|
+
let bell;
|
|
569
|
+
const expired = new Promise(resolve => {
|
|
570
|
+
bell = setTimeout(() => resolve(notYet(now)), deadlineMs);
|
|
571
|
+
// A deadline is not a reason for the process to stay alive: this timer
|
|
572
|
+
// outlives nothing, and an exit waiting on it would be this function
|
|
573
|
+
// holding the deck open for an answer nobody is there to read.
|
|
574
|
+
bell.unref?.();
|
|
575
|
+
});
|
|
576
|
+
return Promise.race([read, expired]).finally(() => clearTimeout(bell));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** What the deck can honestly say about a reading it has not finished taking. */
|
|
580
|
+
function notYet(now) {
|
|
581
|
+
if (_lastGood) return { ..._lastGood, stale: true };
|
|
582
|
+
return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
|
|
499
583
|
}
|
|
500
584
|
|
|
501
585
|
/**
|
|
@@ -640,7 +724,13 @@ async function _execOnce(bin) {
|
|
|
640
724
|
_saidFailure = null;
|
|
641
725
|
}
|
|
642
726
|
const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
|
|
643
|
-
|
|
727
|
+
// WHETHER THE RUN ITSELF SUCCEEDED, which `cliOk` does not answer. `cliOk` is
|
|
728
|
+
// a test of the OUTPUT — it means "we recognised what came back" — and a CLI
|
|
729
|
+
// that printed its banner and then failed satisfies it. That is the right
|
|
730
|
+
// rule for the parse above (see the note there: the quota lines can be on
|
|
731
|
+
// stdout and the exit non-zero), and the wrong one for the no-numbers
|
|
732
|
+
// fallback in _doFetch, which was publishing 0% for a run that errored.
|
|
733
|
+
return { cliOk, ran: r.ok, missing, parsed: parseUsageText(combined) };
|
|
644
734
|
}
|
|
645
735
|
|
|
646
736
|
async function _doFetch(now, force = false, gen = _generation) {
|
|
@@ -693,11 +783,13 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
693
783
|
// refreshed). The real lines appear on a subsequent call. Retry a couple
|
|
694
784
|
// times before giving up so the first paint already shows real values.
|
|
695
785
|
let cliOk = false;
|
|
786
|
+
let cliRan = false;
|
|
696
787
|
let parsed = null;
|
|
697
788
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
698
789
|
if (attempt > 0) await sleep(1200);
|
|
699
790
|
const r = await _execOnce(bin);
|
|
700
791
|
cliOk = r.cliOk || cliOk;
|
|
792
|
+
cliRan = r.ran || cliRan;
|
|
701
793
|
if (r.parsed) { parsed = r.parsed; break; }
|
|
702
794
|
// The retry exists for a CLI that RAN and left the quota lines out of a cold
|
|
703
795
|
// invocation. A CLI that is not installed will not be installed 1.2 seconds
|
|
@@ -734,11 +826,24 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
734
826
|
//
|
|
735
827
|
// A CLI that failed entirely is `ok: false` as it always was, and the reason
|
|
736
828
|
// says which of the two the reader is looking at.
|
|
737
|
-
|
|
738
|
-
|
|
829
|
+
//
|
|
830
|
+
// `cliRan` IS WHAT MAKES THAT SENTENCE TRUE. It used to rest on `cliOk`
|
|
831
|
+
// alone, which is a regex over stdout+stderr and never looked at the exit
|
|
832
|
+
// status — so a CLI that printed its header and then failed (a network
|
|
833
|
+
// error, a rate limit, a timeout after the banner) took this branch and the
|
|
834
|
+
// panel drew two full-looking bars reading "5-hour window 0%". The deck
|
|
835
|
+
// logged `claude CLI failed` in the same second. Someone reading an empty
|
|
836
|
+
// window then starts a long run against one that is nearly spent.
|
|
837
|
+
//
|
|
838
|
+
// Only this branch is gated. The parse above still keeps its output on a
|
|
839
|
+
// non-zero exit, deliberately, because the quota lines can be printed and
|
|
840
|
+
// the exit still be non-zero — 0% is a claim made in the ABSENCE of numbers,
|
|
841
|
+
// and absence plus failure is not a measurement.
|
|
842
|
+
const subscribed = cliOk && cliRan ? await hasSubscriptionCredential() : false;
|
|
843
|
+
const result = cliOk && cliRan && subscribed
|
|
739
844
|
? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
|
|
740
845
|
week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
|
|
741
|
-
: { ok: false, reason: cliOk ? "no_subscription" : "cli_failed", fetchedAt: now };
|
|
846
|
+
: { ok: false, reason: cliOk && cliRan ? "no_subscription" : "cli_failed", fetchedAt: now };
|
|
742
847
|
return publish(gen, result, now - (CACHE_MS - 5_000));
|
|
743
848
|
}
|
|
744
849
|
|
|
@@ -61,6 +61,7 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
61
61
|
import { join } from "node:path";
|
|
62
62
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
63
63
|
import { challengeDeck, isProcessAlive } from "./deck-probe.mjs";
|
|
64
|
+
import { sameCodexTree } from "./log-writer.mjs";
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
67
|
* Where every deck on this machine registers itself.
|
|
@@ -84,13 +85,26 @@ export function deckRegistryDir(env = process.env, home = undefined) {
|
|
|
84
85
|
* either field existed carries `undefined` and fails — and a deck that old is
|
|
85
86
|
* replaced rather than attached to, which is what a start does with any deck
|
|
86
87
|
* it cannot vouch for.
|
|
88
|
+
*
|
|
89
|
+
* AND THE CODEX TREE, which is the one field that is not strict. #1110 put the
|
|
90
|
+
* tree a deck tails on its record, and until this read it a start for another
|
|
91
|
+
* tree matched on the four fields above and attached: `CODEX_HOME=/srv/codex
|
|
92
|
+
* ccdeck` opened the deck already reading ~/.codex, and the tree it was started
|
|
93
|
+
* for was never read by anything. A different tree is a different canvas, so it
|
|
94
|
+
* is a different shape. Compared by sameCodexTree, the log election's own rule
|
|
95
|
+
* for the same field — so the two cannot disagree about whether two spellings
|
|
96
|
+
* are one tree — which also means a side that does not say is taken as the
|
|
97
|
+
* same: a record written before #1110 keeps the answer it had. Every caller
|
|
98
|
+
* that asks on behalf of a start names its tree: the start itself, and
|
|
99
|
+
* `--status` marking the deck a bare start would open (#1134).
|
|
87
100
|
*/
|
|
88
101
|
export function sameShape(record, want = {}) {
|
|
89
102
|
if (!record) return false;
|
|
90
103
|
return (record.workspace ?? "") === (want.workspace ?? "")
|
|
91
104
|
&& (record.persist ?? null) === (want.persist ?? null)
|
|
92
105
|
&& record.codex === (want.codex !== false)
|
|
93
|
-
&& record.claude === (want.claude !== false)
|
|
106
|
+
&& record.claude === (want.claude !== false)
|
|
107
|
+
&& sameCodexTree(record.codexHome, want.codexHome);
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
/**
|