ccdeck 1.43.0 → 1.45.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 +210 -12
- package/bin/agent-dag.js +505 -0
- package/bin/deck.js +1051 -0
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +496 -0
- package/package.json +42 -9
- package/src/server/args.mjs +156 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +388 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +411 -0
- package/src/server/cswap-admin.mjs +1034 -0
- package/src/server/cswap-auto.mjs +595 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +938 -0
- package/src/server/index.mjs +5109 -0
- package/src/server/installer.mjs +763 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/supervisor.mjs +302 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// What bin/agent-dag.js does when the worker dies: bring it back, fetch a
|
|
2
|
+
// newer one, or let the whole thing stop — and, for a worker that was killed
|
|
3
|
+
// rather than exited, how to hand that on to whoever started the deck. Plus the
|
|
4
|
+
// question that now comes BEFORE any of that, since the fetch happens while the
|
|
5
|
+
// worker is still serving: whether this upgrade is worth attempting at all.
|
|
6
|
+
//
|
|
7
|
+
// It lives here rather than inline in the supervisor because the supervisor
|
|
8
|
+
// launches a real child process the moment it is imported, so the rule could
|
|
9
|
+
// not be checked any other way — and it is a rule with a race in it.
|
|
10
|
+
//
|
|
11
|
+
// The race: the worker exits 75 because the user clicked Restart, and Ctrl+C
|
|
12
|
+
// lands in the same instant. The supervisor's signal handler runs first and
|
|
13
|
+
// sets `stopping`, then the exit event arrives carrying 75 — and a supervisor
|
|
14
|
+
// that reads the code without asking whether it is still supposed to be
|
|
15
|
+
// running spawns a fresh deck AFTER the user stopped it. That deck prints
|
|
16
|
+
// `restarted → vX` over the shutdown and keeps serving until the handler's
|
|
17
|
+
// 2.5s retry timer happens to kill it; the 76 variant starts a whole npx
|
|
18
|
+
// registry fetch first. So `stopping` outranks the code, always.
|
|
19
|
+
//
|
|
20
|
+
// Ctrl+C reaches this process on every platform we support — POSIX delivers it
|
|
21
|
+
// to the foreground process group, and on Windows the console raises
|
|
22
|
+
// CTRL_C_EVENT for every process attached to it, which Node surfaces as
|
|
23
|
+
// 'SIGINT'. What differs is only how the child dies, which is not this
|
|
24
|
+
// function's business.
|
|
25
|
+
|
|
26
|
+
import { constants } from "node:os";
|
|
27
|
+
|
|
28
|
+
// Chosen because they mean nothing else here: the worker exits 0 normally and
|
|
29
|
+
// non-zero on failure, both of which must pass straight through.
|
|
30
|
+
export const RESTART_CODE = 75; // come back running the files on disk
|
|
31
|
+
export const UPGRADE_CODE = 76; // come back through npx, which fetches newer files
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a dead worker means.
|
|
35
|
+
*
|
|
36
|
+
* { relaunch: "disk" } — spawn it again from the files on disk
|
|
37
|
+
* { relaunch: "npx" } — spawn it again through npx, which fetches newer ones
|
|
38
|
+
* { relaunch: null, code } — stop, exiting with `code`
|
|
39
|
+
*
|
|
40
|
+
* `stopping` is a parameter rather than the supervisor's module-level flag for
|
|
41
|
+
* the same reason `spawnSpec` takes a platform: it is the input that decides
|
|
42
|
+
* the answer, and both answers have to be testable.
|
|
43
|
+
*
|
|
44
|
+
* A stop exits 0 for 75 and 76. Those two are a private protocol between the
|
|
45
|
+
* worker and its supervisor — nobody outside knows they mean "come back", and
|
|
46
|
+
* 75 is EX_TEMPFAIL to anything that reads sysexits, so handing either to the
|
|
47
|
+
* shell would report a failure the user did not have. Every other code is the
|
|
48
|
+
* worker's own verdict and passes through untouched.
|
|
49
|
+
*/
|
|
50
|
+
export function workerExitAction(code, stopping) {
|
|
51
|
+
const ours = code === RESTART_CODE || code === UPGRADE_CODE;
|
|
52
|
+
if (ours && !stopping) return { relaunch: code === UPGRADE_CODE ? "npx" : "disk" };
|
|
53
|
+
return { relaunch: null, code: ours ? 0 : code ?? 0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How to report a worker that did not exit at all but was killed by a signal.
|
|
58
|
+
*
|
|
59
|
+
* { reraise: "SIGHUP", code: 129 } — die of the same signal; `code` is only
|
|
60
|
+
* the fallback if that somehow returns
|
|
61
|
+
* { reraise: null, code: 129 } — no signal to die of, exit with the number
|
|
62
|
+
*
|
|
63
|
+
* A supervisor that just exits 0 here tells the shell the deck stopped cleanly
|
|
64
|
+
* after somebody killed it. The status a caller expects is the shell's own
|
|
65
|
+
* convention, 128 + the signal number: 129 for SIGHUP, 143 for SIGTERM. On
|
|
66
|
+
* POSIX the honest way to produce it is to die of the signal rather than to
|
|
67
|
+
* exit with the arithmetic — only that also sets the "killed by a signal" bit
|
|
68
|
+
* every wait(2)-based caller reads, and `$?` comes out the same either way.
|
|
69
|
+
*
|
|
70
|
+
* Windows has no signals: a process there cannot die of one, and `process.kill`
|
|
71
|
+
* against ourselves would be a TerminateProcess with an unrelated status. So
|
|
72
|
+
* the number is all we can offer, and it is still better than 0 — the deck was
|
|
73
|
+
* killed, and whatever started it should be able to see that.
|
|
74
|
+
*
|
|
75
|
+
* The signal numbers come from `os.constants.signals`, which is also the table
|
|
76
|
+
* `process.kill` accepts, so a name missing from it is a name we could not
|
|
77
|
+
* re-raise anyway; it becomes a plain failure exit.
|
|
78
|
+
*/
|
|
79
|
+
export function signalExitAction(signal, platform = process.platform, numbers = constants.signals) {
|
|
80
|
+
const n = numbers?.[signal];
|
|
81
|
+
if (typeof n !== "number") return { reraise: null, code: 1 };
|
|
82
|
+
return { reraise: platform === "win32" ? null : signal, code: 128 + n };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* End this process the way the worker ended: killed by `signal`.
|
|
87
|
+
*
|
|
88
|
+
* The re-raise is the obvious half and on its own it does not work, which is
|
|
89
|
+
* the reason this is a function rather than one line at the call site. The
|
|
90
|
+
* supervisor traps SIGINT, SIGTERM and SIGHUP — and those are exactly the
|
|
91
|
+
* signals a worker is most likely to die of, since deck.js does not handle
|
|
92
|
+
* SIGHUP at all and registers the other two only after a boot that takes
|
|
93
|
+
* seconds. For all three the re-raise lands back in our own handler, which
|
|
94
|
+
* finds no child left and exits 0: `kill -HUP` on the deck was reported to the
|
|
95
|
+
* shell as a clean, successful stop. Dropping the handler first restores the
|
|
96
|
+
* default action, which is to die of the signal.
|
|
97
|
+
*
|
|
98
|
+
* `proc` is a parameter so the whole sequence can be driven in a test child.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* What to say when the upgrade that just succeeded took this install with it,
|
|
102
|
+
* or null when that is not what happened.
|
|
103
|
+
*
|
|
104
|
+
* `npm i -g ccdeck` performed before #340 nests the deck inside a launcher
|
|
105
|
+
* package. Upgrading such an install now writes the deck itself over that
|
|
106
|
+
* launcher, and npm's reify removes everything that was underneath — including
|
|
107
|
+
* the directory this supervisor and its worker are running from. The process
|
|
108
|
+
* survives, because everything it needs is already loaded. The next spawn does
|
|
109
|
+
* not: bin/deck.js is a path, and npm has just deleted it.
|
|
110
|
+
*
|
|
111
|
+
* That reached the user as `could not start …/bin/deck.js: ENOENT` and an exit
|
|
112
|
+
* 1 — a dead deck and an errno, one click after an upgrade that reported
|
|
113
|
+
* success. Nothing was broken. The new version is on the machine, and the
|
|
114
|
+
* command they typed already points at it, because npm rewrote the bin shim in
|
|
115
|
+
* the same install. The only thing missing was a sentence saying so.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately not a hand-off. This process could spawn the successor's worker
|
|
118
|
+
* instead, and that would be a new worker under an old supervisor — a pair
|
|
119
|
+
* nothing has ever tested together, decided at the worst possible moment. One
|
|
120
|
+
* command the user runs themselves is the smaller promise and the one that
|
|
121
|
+
* cannot go wrong.
|
|
122
|
+
*
|
|
123
|
+
* Pure, and given its two filesystem answers rather than asking for them,
|
|
124
|
+
* because the state it describes only ever exists in a process whose own files
|
|
125
|
+
* were deleted after it started — which no test can produce by spawning one.
|
|
126
|
+
*/
|
|
127
|
+
export function replacedNote({ workerExists, moved, product = "ccdeck", command = "ccdeck" }) {
|
|
128
|
+
if (workerExists || !moved) return null;
|
|
129
|
+
return [
|
|
130
|
+
`${product}: the upgrade replaced this install, so it cannot restart in place.`,
|
|
131
|
+
` the new version is in ${moved}`,
|
|
132
|
+
` run \`${command}\` again to start it`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function dieOfSignal(signal, proc = process) {
|
|
137
|
+
const { reraise, code } = signalExitAction(signal, proc.platform);
|
|
138
|
+
if (reraise) {
|
|
139
|
+
proc.removeAllListeners(reraise);
|
|
140
|
+
try { proc.kill(proc.pid, reraise); } catch { /* fall through to the number */ }
|
|
141
|
+
}
|
|
142
|
+
// Reached on Windows, and on POSIX only when the signal is ignored or
|
|
143
|
+
// blocked — inherited dispositions survive spawn — so the kill above returns
|
|
144
|
+
// instead of ending us.
|
|
145
|
+
proc.exit(code);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── how often an upgrade that failed may be attempted again ──────────────────
|
|
149
|
+
//
|
|
150
|
+
// Reported from a terminal that had been left alone: the same version, the same
|
|
151
|
+
// npm error, the same teardown, four times over. Nothing anywhere remembered
|
|
152
|
+
// that this exact target had already failed, so every path back into the deck
|
|
153
|
+
// offered the identical attempt — and each one cost a real interruption.
|
|
154
|
+
//
|
|
155
|
+
// Five minutes is chosen against what actually fails. A registry blip or a
|
|
156
|
+
// version that has not finished propagating is gone well inside it; being
|
|
157
|
+
// offline, behind a proxy that blocks the registry, or on the broken npx shim
|
|
158
|
+
// of #184 is not, and no amount of retrying will change that. So the second
|
|
159
|
+
// attempt waits, and there is no third: the copyable command is already on
|
|
160
|
+
// screen beside the failure and is the honest escape hatch. A newer target
|
|
161
|
+
// resets both — that is a different question, and it deserves its own answer.
|
|
162
|
+
export const UPGRADE_COOLDOWN_MS = 300_000;
|
|
163
|
+
export const UPGRADE_MAX_ATTEMPTS = 2;
|
|
164
|
+
|
|
165
|
+
/** Whether a note on disk is a failure at all, and one about `target`.
|
|
166
|
+
*
|
|
167
|
+
* Both sides of the comparison can be null — the version check is cached for
|
|
168
|
+
* an hour and can be off entirely — and two unknowns count as the same target
|
|
169
|
+
* rather than as two different ones. Guessing "different" would hand back the
|
|
170
|
+
* unbounded retry this exists to end. A note from a deck older than this rule
|
|
171
|
+
* carries no target at all, which is genuinely unknown against a target we do
|
|
172
|
+
* know, so it is not held against a fresh version. */
|
|
173
|
+
function noteAbout(note, target) {
|
|
174
|
+
if (!note || typeof note.error !== "string" || !note.error) return false;
|
|
175
|
+
return (note.target ?? null) === (target ?? null);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** How many attempts the note already stands for. A note written before this
|
|
179
|
+
* rule existed has no count and is exactly one failure. */
|
|
180
|
+
function attemptsOf(note) {
|
|
181
|
+
return Number.isInteger(note?.attempts) && note.attempts > 0 ? note.attempts : 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** When the attempt behind the note actually failed, which is not when the note
|
|
185
|
+
* was last written: a refusal re-stamps `at` so the browser can tell it from
|
|
186
|
+
* the failure before it, and the cooldown must not be pushed out by being
|
|
187
|
+
* asked. */
|
|
188
|
+
function failedAtOf(note) {
|
|
189
|
+
if (typeof note?.failedAt === "number") return note.failedAt;
|
|
190
|
+
return typeof note?.at === "number" ? note.at : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether an upgrade to `target` may be attempted, given what the last one left
|
|
195
|
+
* behind.
|
|
196
|
+
*
|
|
197
|
+
* { allow: true, attempt: n } — go ahead; this is the nth
|
|
198
|
+
* { allow: false, reason: "cooling", waitMs, attempt }
|
|
199
|
+
* { allow: false, reason: "exhausted", attempt }
|
|
200
|
+
*
|
|
201
|
+
* Pure, and the note is a parameter rather than a read, because this is the
|
|
202
|
+
* rule that has to terminate an unattended loop and a rule worth a bug is worth
|
|
203
|
+
* a test.
|
|
204
|
+
*/
|
|
205
|
+
export function upgradeAttempt({
|
|
206
|
+
note, target = null, now = Date.now(),
|
|
207
|
+
cooldownMs = UPGRADE_COOLDOWN_MS, maxAttempts = UPGRADE_MAX_ATTEMPTS,
|
|
208
|
+
} = {}) {
|
|
209
|
+
if (!noteAbout(note, target)) return { allow: true, attempt: 1 };
|
|
210
|
+
const attempt = attemptsOf(note);
|
|
211
|
+
if (attempt >= maxAttempts) return { allow: false, reason: "exhausted", attempt, waitMs: 0 };
|
|
212
|
+
const failedAt = failedAtOf(note);
|
|
213
|
+
// A clock that moved backwards — a laptop waking, an NTP correction — must
|
|
214
|
+
// not leave the deck cooling forever, so an unmeasurable wait counts as
|
|
215
|
+
// elapsed. The attempt cap is what makes that safe: it holds however the
|
|
216
|
+
// clock behaves, and it is the half that ends the loop.
|
|
217
|
+
const waited = typeof failedAt === "number" && now >= failedAt ? now - failedAt : cooldownMs;
|
|
218
|
+
if (waited < cooldownMs) {
|
|
219
|
+
return { allow: false, reason: "cooling", attempt, waitMs: cooldownMs - waited };
|
|
220
|
+
}
|
|
221
|
+
return { allow: true, attempt: attempt + 1 };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The refusal as the deck should state it — this is the whole of what the
|
|
225
|
+
* banner and the terminal say, so it has to name the version, say what already
|
|
226
|
+
* happened to it, and leave the user somewhere to go. The copyable command is
|
|
227
|
+
* already on screen next to it. */
|
|
228
|
+
export function upgradeRefusalText({ reason, waitMs = 0, attempt = 0 } = {}, target = null) {
|
|
229
|
+
const what = target ? `v${target}` : "this update";
|
|
230
|
+
if (reason === "exhausted") {
|
|
231
|
+
return `${what} failed to fetch ${attempt} times — not trying again from here; run the command yourself`;
|
|
232
|
+
}
|
|
233
|
+
const left = waitMs >= 60_000 ? `${Math.ceil(waitMs / 60_000)}m` : `${Math.max(1, Math.ceil(waitMs / 1000))}s`;
|
|
234
|
+
return `${what} failed to fetch a moment ago — waiting ${left} before trying again`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Die when the process that started this one does — on all three operating
|
|
239
|
+
* systems (#702).
|
|
240
|
+
*
|
|
241
|
+
* The deck is two processes: bin/agent-dag.js supervises, bin/deck.js serves.
|
|
242
|
+
* The supervisor kills the worker on every path it knows about — a restart, an
|
|
243
|
+
* upgrade, a Ctrl+C, its own exit — and cannot kill it on the one path it never
|
|
244
|
+
* gets to run: being killed itself. SIGKILL cannot be handled, a crash runs no
|
|
245
|
+
* handler, and a `taskkill` without `/T` reaches only the process it names. On
|
|
246
|
+
* POSIX the worker is then re-parented to init and keeps its port, its temp
|
|
247
|
+
* directory and its 40-60 MB of RSS forever. 310 of those were alive on one
|
|
248
|
+
* development machine, the oldest a day and four hours old, every one a worker
|
|
249
|
+
* whose supervisor a test's teardown had SIGKILLed.
|
|
250
|
+
*
|
|
251
|
+
* WHY THE IPC CHANNEL, AND NOT ANY OF THE OBVIOUS ALTERNATIVES. The requirement
|
|
252
|
+
* is one signal meaning "whoever started me is gone", and the usual answers are
|
|
253
|
+
* each missing a platform:
|
|
254
|
+
*
|
|
255
|
+
* • `process.ppid === 1` — POSIX re-parents an orphan to init, so polling ppid
|
|
256
|
+
* works there and answers nothing on Windows, which does not re-parent at
|
|
257
|
+
* all: the ppid goes on naming a pid that no longer exists.
|
|
258
|
+
* • process groups and `kill(-pgid)` — a POSIX concept. Windows job objects
|
|
259
|
+
* are the nearest equivalent and Node exposes none of it.
|
|
260
|
+
* • a SIGTERM the parent sends on its way out — Windows delivers no signals to
|
|
261
|
+
* a Node process, so `process.on("SIGTERM")` there never fires; and a parent
|
|
262
|
+
* that was killed sends nothing anywhere.
|
|
263
|
+
*
|
|
264
|
+
* The channel is the one mechanism that behaves the same everywhere, because
|
|
265
|
+
* Node normalises it: a Unix socketpair on POSIX, a named pipe on Windows, both
|
|
266
|
+
* closed by the kernel when the process holding the other end stops existing,
|
|
267
|
+
* however it stopped. Node turns that close into a single `disconnect` event on
|
|
268
|
+
* the child's own `process`, and it arrives for a SIGKILL, a crash, a
|
|
269
|
+
* `taskkill /F`, a closed console window and an ordinary exit alike. That is the
|
|
270
|
+
* same reasoning the header above gives for Ctrl+C: what differs between
|
|
271
|
+
* platforms is how a process dies, not that this notices.
|
|
272
|
+
*
|
|
273
|
+
* The channel is not created for this. It already carries `{type:"listening"}`
|
|
274
|
+
* and the upgrade handshake; this only says what its closure means.
|
|
275
|
+
*
|
|
276
|
+
* ARMED ONLY WHERE THERE IS A PARENT TO DIE WITH — `process.send` is a function
|
|
277
|
+
* exactly when a channel was given. `node bin/deck.js` run by hand has none, and
|
|
278
|
+
* that is the same question `SUPERVISED` in bin/deck.js already asks before it
|
|
279
|
+
* offers /api/restart.
|
|
280
|
+
*
|
|
281
|
+
* WHAT IT DELIBERATELY DOES NOT COVER: a parent that calls `child.disconnect()`
|
|
282
|
+
* and stays alive would look identical from here. Nothing in this repo does
|
|
283
|
+
* that, and the alternative — an "are you still there" ping — is a second
|
|
284
|
+
* protocol to keep correct in exchange for a case that does not exist.
|
|
285
|
+
*
|
|
286
|
+
* Returns whether a leash was armed, so a caller can state it rather than infer
|
|
287
|
+
* it. `proc` is a parameter for the reason `workerExitAction` takes `stopping`:
|
|
288
|
+
* the behaviour has to be checkable without a second process.
|
|
289
|
+
*/
|
|
290
|
+
export function dieWithParent(stop, proc = process) {
|
|
291
|
+
if (!proc || typeof proc.once !== "function" || typeof proc.send !== "function") return false;
|
|
292
|
+
let done = false;
|
|
293
|
+
proc.once("disconnect", () => {
|
|
294
|
+
if (done) return;
|
|
295
|
+
done = true;
|
|
296
|
+
// A stop that throws must still end the process: the whole point is that
|
|
297
|
+
// nothing is left behind, and there is no parent left to notice a child
|
|
298
|
+
// that failed to leave.
|
|
299
|
+
try { stop(); } catch { proc.exit?.(0); }
|
|
300
|
+
});
|
|
301
|
+
return true;
|
|
302
|
+
}
|