ccdeck 3.36.8 → 3.37.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 +15 -7
- package/bin/agent-dag.js +19 -6
- package/bin/cli/help.js +1 -0
- package/bin/cli/leaving.js +54 -0
- package/bin/cli/login-item.js +44 -20
- package/bin/cli/one-shot.js +15 -5
- package/bin/cli/screen.js +10 -5
- package/bin/cli/startup.js +6 -2
- package/bin/cli/uninstall.js +45 -0
- package/bin/deck.js +50 -8
- package/dist/web/assets/BrowserWatchModal-DCHV09J8.js +1 -0
- package/dist/web/assets/UsageHistoryModal-oDjpHw4z.js +1 -0
- package/dist/web/assets/index-B0SOa-XB.css +1 -0
- package/dist/web/assets/index-wCbqcRJd.js +306 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +80 -9
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/account-origins.mjs +8 -1
- package/src/server/account-projects.mjs +34 -9
- package/src/server/activation.mjs +39 -10
- package/src/server/app-host.mjs +7 -2
- package/src/server/args.mjs +3 -0
- package/src/server/browser-watch-routes.mjs +23 -16
- package/src/server/browser-watch.mjs +9 -3
- package/src/server/claude-accounts.mjs +27 -7
- package/src/server/claude-verdicts.mjs +12 -1
- package/src/server/cli-versions.mjs +6 -4
- package/src/server/codex-auth.mjs +29 -0
- package/src/server/codex-quota.mjs +57 -7
- package/src/server/deck-home.mjs +23 -1
- package/src/server/deck-prefs.mjs +70 -7
- package/src/server/depth-facts.mjs +178 -0
- package/src/server/detach.mjs +61 -33
- package/src/server/event-log.mjs +15 -0
- package/src/server/event-pipeline.mjs +5 -2
- package/src/server/event-routes.mjs +22 -0
- package/src/server/feedback-form.mjs +36 -13
- package/src/server/index.mjs +6 -2
- package/src/server/install-layout.mjs +35 -0
- package/src/server/install-ref.mjs +39 -0
- package/src/server/installer.mjs +20 -2
- package/src/server/lan-beacon.mjs +18 -3
- package/src/server/lan-deck.mjs +7 -1
- package/src/server/lan-engine.mjs +10 -1
- package/src/server/lan-manifest.mjs +9 -6
- package/src/server/lan-requests.mjs +18 -2
- package/src/server/lan-serve.mjs +4 -2
- package/src/server/lan-sync.mjs +97 -6
- package/src/server/lifecycle.mjs +18 -2
- package/src/server/log-replay.mjs +9 -3
- package/src/server/login-service.mjs +95 -12
- package/src/server/npm-upgrade.mjs +12 -4
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/prefs-routes.mjs +5 -8
- package/src/server/prefs-state.mjs +9 -1
- package/src/server/quota-oauth.mjs +88 -13
- package/src/server/quota-shape.mjs +28 -0
- package/src/server/quota.mjs +68 -17
- package/src/server/rating.mjs +87 -0
- package/src/server/reports-routes.mjs +21 -2
- package/src/server/reports.mjs +704 -63
- package/src/server/ring-bounds.mjs +11 -0
- package/src/server/running-deck.mjs +48 -0
- package/src/server/self-update.mjs +1 -1
- package/src/server/session-enrichment.mjs +14 -6
- package/src/server/stable-node.mjs +68 -0
- package/src/server/static-serve.mjs +5 -0
- package/src/server/supervisor.mjs +21 -15
- package/src/server/tailscale.mjs +8 -4
- package/src/server/transcript-scan.mjs +7 -4
- package/src/server/usage-day.mjs +76 -11
- package/dist/web/assets/BrowserWatchModal-CeVGLslQ.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BcUzGS2-.js +0 -1
- package/dist/web/assets/index-4ecbC_Cy.js +0 -306
- package/dist/web/assets/index-BkXTN4dZ.css +0 -1
|
@@ -14,7 +14,7 @@ import { readFile } from "node:fs/promises";
|
|
|
14
14
|
import { cooldownFromHeader } from "./quota-oauth.mjs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
17
|
-
import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
|
|
17
|
+
import { codexCredentialFingerprint, getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
|
|
18
18
|
import { PRODUCT } from "./brand.mjs";
|
|
19
19
|
import { resetLabel } from "./reset-label.mjs";
|
|
20
20
|
|
|
@@ -28,6 +28,11 @@ const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
|
28
28
|
let _cache = null;
|
|
29
29
|
let _cacheAt = 0;
|
|
30
30
|
const CACHE_MS = 60_000;
|
|
31
|
+
// The last reading that had lanes in it, apart from `_cache`, which also holds
|
|
32
|
+
// failures. A 429 is the backend asking for a pause, not a statement that the
|
|
33
|
+
// lanes read a minute ago have stopped being true, so they are what is held
|
|
34
|
+
// through it — as quota.mjs keeps its own `_lastGood` for the same case.
|
|
35
|
+
let _lastGood = null;
|
|
31
36
|
|
|
32
37
|
// The last base URL we refused, so the refusal is said once rather than once a
|
|
33
38
|
// minute for as long as the config stays that way.
|
|
@@ -280,6 +285,12 @@ const FORCE_POLL_MS = 60_000;
|
|
|
280
285
|
// _rateLimitedUntil, which is likewise never beaten by force.
|
|
281
286
|
let _rateLimitedUntil = 0;
|
|
282
287
|
const COOLDOWN_MS = 5 * 60_000;
|
|
288
|
+
// Set when the cooldown above is for a refused login rather than a 429: the
|
|
289
|
+
// fingerprint of the credential auth.json held when it was refused. That wait
|
|
290
|
+
// is about the credential, and `codex login` — the very thing the panel tells
|
|
291
|
+
// the user to run — replaces it; held to the clock alone, ↻ was answered with
|
|
292
|
+
// the old refusal for five minutes after the login that fixed it.
|
|
293
|
+
let _refusedWith = null;
|
|
283
294
|
|
|
284
295
|
// Stamped when a fetch STARTS rather than when it lands: what the floor is
|
|
285
296
|
// rationing is the round trip, and one that is still in flight has already been
|
|
@@ -323,10 +334,27 @@ export function fetchCodexQuota({ force = false } = {}) {
|
|
|
323
334
|
// that has not landed yet is one.
|
|
324
335
|
if (_inflight) return _inflight;
|
|
325
336
|
if (!mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: _rateLimitedUntil })) {
|
|
337
|
+
// Past the floor and inside a refused login's cooldown, the one question
|
|
338
|
+
// worth asking is whether auth.json still holds what was refused — a local
|
|
339
|
+
// read, and the wire stays quiet unless the answer is no.
|
|
340
|
+
if (_refusedWith && mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: 0 })) {
|
|
341
|
+
return track((async () => {
|
|
342
|
+
if (await codexCredentialFingerprint() === _refusedWith) return heldReading(Date.now());
|
|
343
|
+
_rateLimitedUntil = 0;
|
|
344
|
+
_refusedWith = null;
|
|
345
|
+
_lastFetchAt = Date.now();
|
|
346
|
+
return doFetchCodexQuota();
|
|
347
|
+
})());
|
|
348
|
+
}
|
|
326
349
|
return Promise.resolve(heldReading(now));
|
|
327
350
|
}
|
|
328
351
|
_lastFetchAt = now;
|
|
329
|
-
|
|
352
|
+
return track(doFetchCodexQuota());
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** `run`, as the one outstanding fetch every caller joins until it settles. */
|
|
356
|
+
function track(run) {
|
|
357
|
+
_inflight = run.finally(() => { _inflight = null; });
|
|
330
358
|
return _inflight;
|
|
331
359
|
}
|
|
332
360
|
|
|
@@ -335,7 +363,12 @@ async function doFetchCodexQuota() {
|
|
|
335
363
|
// Stamped at completion, not at entry: the two calls below can take up to
|
|
336
364
|
// 17s between them, and a cache entry that is already stale on arrival
|
|
337
365
|
// shortens the effective TTL for no reason.
|
|
338
|
-
const finish = (r) => {
|
|
366
|
+
const finish = (r) => {
|
|
367
|
+
_cache = r;
|
|
368
|
+
_cacheAt = Date.now();
|
|
369
|
+
if (r.ok && !r.stale) _lastGood = r;
|
|
370
|
+
return r;
|
|
371
|
+
};
|
|
339
372
|
const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
|
|
340
373
|
// A refusal we were told about, rather than one we inferred: back off further
|
|
341
374
|
// than the ordinary floor before asking again. `retry-after` is honoured when
|
|
@@ -345,6 +378,13 @@ async function doFetchCodexQuota() {
|
|
|
345
378
|
// defeats the cooldown a 429 exists to impose, and a day freezes this
|
|
346
379
|
// poller for the life of the process.
|
|
347
380
|
_rateLimitedUntil = Date.now() + cooldownFromHeader(res?.headers?.get?.("retry-after"), COOLDOWN_MS);
|
|
381
|
+
_refusedWith = null;
|
|
382
|
+
};
|
|
383
|
+
// The same wait for a login that was refused, stamped with the credential it
|
|
384
|
+
// was refused for, so a new one ends it (see fetchCodexQuota).
|
|
385
|
+
const refused = async (res) => {
|
|
386
|
+
cooldown(res);
|
|
387
|
+
_refusedWith = await codexCredentialFingerprint();
|
|
348
388
|
};
|
|
349
389
|
|
|
350
390
|
let auth, base, res;
|
|
@@ -388,7 +428,7 @@ async function doFetchCodexQuota() {
|
|
|
388
428
|
// The credential is gone and only `codex login` brings it back, so
|
|
389
429
|
// rotating another single-use token at the next request would burn the
|
|
390
430
|
// one the CLI is still holding. Wait.
|
|
391
|
-
if (refreshed.reason === "refresh_rejected")
|
|
431
|
+
if (refreshed.reason === "refresh_rejected") await refused(null);
|
|
392
432
|
return fail(refreshed.reason);
|
|
393
433
|
}
|
|
394
434
|
auth = refreshed;
|
|
@@ -398,9 +438,19 @@ async function doFetchCodexQuota() {
|
|
|
398
438
|
if (!res.ok) {
|
|
399
439
|
// A second 401 means the token we just rotated to was rejected as well —
|
|
400
440
|
// the case that turned into one rotation per request. 429 is the backend
|
|
401
|
-
// saying the same thing in the ordinary way
|
|
402
|
-
|
|
403
|
-
|
|
441
|
+
// saying the same thing in the ordinary way, and it says nothing against
|
|
442
|
+
// the lanes already read: they are held, stale, through the wait, and
|
|
443
|
+
// without any the panel is told the deck is waiting rather than to press
|
|
444
|
+
// a ↻ the cooldown refuses.
|
|
445
|
+
if (res.status === 401) {
|
|
446
|
+
await refused(res);
|
|
447
|
+
return fail("refresh_rejected");
|
|
448
|
+
}
|
|
449
|
+
if (res.status === 429) {
|
|
450
|
+
cooldown(res);
|
|
451
|
+
return _lastGood ? finish({ ..._lastGood, stale: true }) : fail("rate_limited");
|
|
452
|
+
}
|
|
453
|
+
return fail(`http_${res.status}`);
|
|
404
454
|
}
|
|
405
455
|
} catch (err) {
|
|
406
456
|
console.error(`${PRODUCT} codex-quota: fetch failed:`, err?.message ?? err);
|
package/src/server/deck-home.mjs
CHANGED
|
@@ -220,6 +220,11 @@ export const TEMP_STALE_MS = 60 * 60 * 1000;
|
|
|
220
220
|
* after the ones that are not — and it is deliberately blind to WHICH deck made
|
|
221
221
|
* a file, because it cannot know: it goes by age instead, which is a fact about
|
|
222
222
|
* the file rather than a guess about a pid.
|
|
223
|
+
*
|
|
224
|
+
* BUT ONLY THE DECK'S OWN NAMES. CCDECK_HOME is used as it is, so these can be
|
|
225
|
+
* somebody's own folder — a USB stick's root, a synced tools folder — and a
|
|
226
|
+
* `report.tmp` or a downloader's staging file in it is theirs. Every shape the
|
|
227
|
+
* deck's writers have ever used is in deckTemp, and nothing else is touched.
|
|
223
228
|
*/
|
|
224
229
|
export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {}) {
|
|
225
230
|
let removed = 0;
|
|
@@ -227,7 +232,7 @@ export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {
|
|
|
227
232
|
let names;
|
|
228
233
|
try { names = await fs.readdir(dir); } catch { continue; }
|
|
229
234
|
for (const name of names) {
|
|
230
|
-
if (!
|
|
235
|
+
if (!deckTemp(name)) continue;
|
|
231
236
|
const path = join(dir, name);
|
|
232
237
|
try {
|
|
233
238
|
const st = await fs.stat(path);
|
|
@@ -241,3 +246,20 @@ export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {
|
|
|
241
246
|
}
|
|
242
247
|
return removed;
|
|
243
248
|
}
|
|
249
|
+
|
|
250
|
+
const escaped = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
251
|
+
const DECK_TEMP = [
|
|
252
|
+
// atomic-write.mjs: `<target>.agent-dag-<pid>-<n>.tmp`, and before the
|
|
253
|
+
// sequence number `<target>.agent-dag-<pid>.tmp`.
|
|
254
|
+
/\.agent-dag-\d+(?:-\d+)?\.tmp$/,
|
|
255
|
+
// The preferences' and the Browser Watch archive's own writers, from before
|
|
256
|
+
// atomic-write.mjs and since: `prefs.json.<pid>.tmp`, `state.json.<pid>.<n>.tmp`.
|
|
257
|
+
/^(?:prefs|state)\.json\.\d+(?:\.\d+)?\.tmp$/,
|
|
258
|
+
// migrateDeckFiles: `<moved file>.<pid>.migrating`.
|
|
259
|
+
new RegExp(`^(?:${MOVED.map(m => escaped(m.name)).join("|")})\\.\\d+\\.migrating$`),
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
/** Is `name` a temp file the deck itself makes? */
|
|
263
|
+
export function deckTemp(name) {
|
|
264
|
+
return DECK_TEMP.some(re => re.test(name));
|
|
265
|
+
}
|
|
@@ -47,6 +47,8 @@ import { deckDataDir } from "./deck-home.mjs";
|
|
|
47
47
|
import { PRODUCT } from "./brand.mjs";
|
|
48
48
|
import { normaliseOrigins } from "./account-origins.mjs";
|
|
49
49
|
import { normaliseUsage } from "./usage-day.mjs";
|
|
50
|
+
import { refSlug } from "./install-ref.mjs";
|
|
51
|
+
import { normaliseRating } from "./rating.mjs";
|
|
50
52
|
|
|
51
53
|
/** Set to "1" to keep the deck off the desktop whatever the stored preference
|
|
52
54
|
* says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
|
|
@@ -93,13 +95,16 @@ export const DEFAULTS = Object.freeze({
|
|
|
93
95
|
// not yet acknowledged, retried on the next start so switching off while
|
|
94
96
|
// offline still ends in a deletion; `usage` is the day's tally of sessions,
|
|
95
97
|
// subagents and projects, counts and days only, so a restart carries it on
|
|
96
|
-
// (usage-day.mjs); `installedAt` is when the id was made,
|
|
98
|
+
// (usage-day.mjs); `installedAt` is when the id was made, on a first run
|
|
99
|
+
// only (an id made over an older deck's settings has none), and
|
|
97
100
|
// `firstSessionAt`/`firstProvider`/`activationSent` the install's first
|
|
98
101
|
// session and whether the "activated" event saying so got through — the times
|
|
99
|
-
// stay here, only a bucket leaves (activation.mjs)
|
|
102
|
+
// stay here, only a bucket leaves (activation.mjs); `rating` is the one
|
|
103
|
+
// question the deck asks about itself, answered or put off (rating.mjs).
|
|
100
104
|
report: Object.freeze({
|
|
101
105
|
installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
|
|
102
106
|
installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
|
|
107
|
+
rating: Object.freeze(normaliseRating(null)),
|
|
103
108
|
}),
|
|
104
109
|
// The accounts this deck signed in itself, and the re-sign-in prompts
|
|
105
110
|
// somebody put off (#1893) — keyed by account identity, written by the deck
|
|
@@ -110,7 +115,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
110
115
|
// write below names a mode. AGENTS_DECK_NO_LAN=1 keeps a deck off the network
|
|
111
116
|
// whatever this file says — see lanEnabled.
|
|
112
117
|
lan: Object.freeze({
|
|
113
|
-
enabled: true, name: "", secret: "", shared: [], manual: [], trusted: [], unpaired: [], port: 0,
|
|
118
|
+
enabled: true, name: "", secret: "", shared: [], onward: [], manual: [], trusted: [], unpaired: [], port: 0,
|
|
114
119
|
// WHO PAIRS WITH WHOM, WITHOUT ANYBODY PRESSING ANYTHING. Asking is on, so
|
|
115
120
|
// two decks on one network find each other and send each other a request —
|
|
116
121
|
// which is what a person with three of their own machines wants and had to
|
|
@@ -227,6 +232,11 @@ function normaliseLan(raw) {
|
|
|
227
232
|
// anybody can act on and refusing to start would take the feature away.
|
|
228
233
|
secret: typeof src.secret === "string" ? src.secret : "",
|
|
229
234
|
shared: strings(src.shared),
|
|
235
|
+
// WHICH OF THOSE AN ARRIVAL TICKED rather than a person (#1188), so a deck
|
|
236
|
+
// the accept switch paired is not offered them — see sharedWith in
|
|
237
|
+
// lan-sync.mjs. Only ever a subset of `shared`: an untick takes the mark
|
|
238
|
+
// with it, so ticking the account again is a person's tick.
|
|
239
|
+
onward: strings(src.onward).filter(key => strings(src.shared).includes(key)),
|
|
230
240
|
manual: strings(src.manual),
|
|
231
241
|
// THE DECKS SOMEBODY PRESSED ACCEPT ON. The public key is the load-bearing
|
|
232
242
|
// half: a fingerprint is a hash of it, so an entry without one cannot be
|
|
@@ -301,7 +311,7 @@ export function normalise(raw) {
|
|
|
301
311
|
}
|
|
302
312
|
|
|
303
313
|
/** The reporter's own state, coerced: strings empty when absent, the day's
|
|
304
|
-
* tally null until there is one, and one
|
|
314
|
+
* tally null until there is one, one flag, and a ref only if it is one. */
|
|
305
315
|
function normaliseReport(raw) {
|
|
306
316
|
const src = raw && typeof raw === "object" ? raw : {};
|
|
307
317
|
const text = v => (typeof v === "string" ? v : "");
|
|
@@ -315,9 +325,21 @@ function normaliseReport(raw) {
|
|
|
315
325
|
firstSessionAt: text(src.firstSessionAt),
|
|
316
326
|
firstProvider: text(src.firstProvider),
|
|
317
327
|
activationSent: src.activationSent === true,
|
|
328
|
+
ref: refSlug(src.ref) ?? "",
|
|
329
|
+
selfUpdate: selfUpdateMarker(src.selfUpdate),
|
|
330
|
+
rating: normaliseRating(src.rating),
|
|
318
331
|
};
|
|
319
332
|
}
|
|
320
333
|
|
|
334
|
+
/** The deck's note that it started an update itself (reports.mjs
|
|
335
|
+
* noteSelfUpdate): the version it left and when, or null. */
|
|
336
|
+
function selfUpdateMarker(raw) {
|
|
337
|
+
if (!raw || typeof raw !== "object") return null;
|
|
338
|
+
const from = typeof raw.from === "string" && /^[0-9A-Za-z.+_-]{1,32}$/.test(raw.from) ? raw.from : "";
|
|
339
|
+
const at = typeof raw.at === "string" && Number.isFinite(Date.parse(raw.at)) ? raw.at : "";
|
|
340
|
+
return from && at ? { from, at } : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
321
343
|
// How a refusal to write is told — the error writePrefs throws, and the reason
|
|
322
344
|
// and the detail the route hands the panel: prefs-refusal.mjs.
|
|
323
345
|
export { prefsRefusalDetail, prefsWriteRefusal };
|
|
@@ -506,11 +528,15 @@ export function withManualEntry(entry) {
|
|
|
506
528
|
* to the next machine as the deck that gave it. Only on arrival — an untick
|
|
507
529
|
* afterwards is the person's answer and nothing re-ticks it, because an account
|
|
508
530
|
* this deck already holds is never added again.
|
|
531
|
+
*
|
|
532
|
+
* AND MARKED AS THE ARRIVAL'S in `lan.onward`, in the same write, so a restart
|
|
533
|
+
* cannot turn it into a tick somebody made — see sharedWith in lan-sync.mjs.
|
|
509
534
|
*/
|
|
510
535
|
export function withShared(key) {
|
|
511
536
|
return prev => {
|
|
512
537
|
const shared = Array.isArray(prev?.lan?.shared) ? prev.lan.shared : [];
|
|
513
|
-
|
|
538
|
+
const onward = Array.isArray(prev?.lan?.onward) ? prev.lan.onward : [];
|
|
539
|
+
return !key || shared.includes(key) ? null : { lan: { shared: [...shared, key], onward: [...onward, key] } };
|
|
514
540
|
};
|
|
515
541
|
}
|
|
516
542
|
|
|
@@ -633,14 +659,51 @@ export function publicPrefs(prefs) {
|
|
|
633
659
|
const { report: _report, accounts: _accounts, ...p } = normalise(prefs);
|
|
634
660
|
// unpaired is engine-authored state too. No page draws or edits it; keeping
|
|
635
661
|
// it out also means a future whole-prefs form cannot replay a stale unpair
|
|
636
|
-
// list over a decision the engine made after the form loaded.
|
|
637
|
-
|
|
662
|
+
// list over a decision the engine made after the form loaded. `onward` is
|
|
663
|
+
// the deck's own bookkeeping of which ticks an arrival made, for the same
|
|
664
|
+
// reason.
|
|
665
|
+
const { secret, trusted, unpaired: _unpaired, onward: _onward, ...lan } = p.lan;
|
|
638
666
|
return {
|
|
639
667
|
...p,
|
|
640
668
|
lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
|
|
641
669
|
};
|
|
642
670
|
}
|
|
643
671
|
|
|
672
|
+
/**
|
|
673
|
+
* The preferences a PAGE may write — publicPrefs's other half, and the fields
|
|
674
|
+
* `POST /api/prefs` takes. Exactly what the page and the desktop app send:
|
|
675
|
+
* the three switches, and the LAN dialog's fields.
|
|
676
|
+
*
|
|
677
|
+
* EVERYTHING ELSE IS THE DECK'S OWN, and kept as it is on disk whatever a body
|
|
678
|
+
* names. The LAN key, the decks somebody accepted or unpaired, which ticks an
|
|
679
|
+
* arrival made and the port are written by the engine and read back at the
|
|
680
|
+
* next start (see lanApplyFields in lan-deck.mjs); a name for another deck
|
|
681
|
+
* goes through its own route, which cleans it; the reports switch only through
|
|
682
|
+
* /api/reports, because turning it off asks for a deletion (#1853); the
|
|
683
|
+
* reporter's state and the accounts the deck signed in (#1893) are the deck's
|
|
684
|
+
* to record. A new preference is not writable from a page until it is named
|
|
685
|
+
* here — prefs-route-fields.test.ts lists them.
|
|
686
|
+
*/
|
|
687
|
+
export const PAGE_FIELDS = Object.freeze({
|
|
688
|
+
top: Object.freeze(["notifications", "tourSeen", "autoUpdate"]),
|
|
689
|
+
lan: Object.freeze([
|
|
690
|
+
"enabled", "name", "shared", "manual", "shareActive", "pairingMode",
|
|
691
|
+
"autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
|
|
692
|
+
]),
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
/** A page's body cut down to PAGE_FIELDS: the patch writePrefs is handed. A
|
|
696
|
+
* field the body does not name is not in it, so it keeps its value. */
|
|
697
|
+
export function pagePatch(body) {
|
|
698
|
+
const pick = (src, keys) => {
|
|
699
|
+
const from = src && typeof src === "object" && !Array.isArray(src) ? src : {};
|
|
700
|
+
return Object.fromEntries(keys.filter(k => Object.hasOwn(from, k)).map(k => [k, from[k]]));
|
|
701
|
+
};
|
|
702
|
+
const patch = pick(body, PAGE_FIELDS.top);
|
|
703
|
+
const lan = pick(body?.lan, PAGE_FIELDS.lan);
|
|
704
|
+
return Object.keys(lan).length ? { ...patch, lan } : patch;
|
|
705
|
+
}
|
|
706
|
+
|
|
644
707
|
/**
|
|
645
708
|
* May the deck raise a desktop notification right now?
|
|
646
709
|
*
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// How the deck holds up and who runs it, for the daily "active" report
|
|
2
|
+
// (reports.mjs), and how an update arrived, for the "update" one.
|
|
3
|
+
//
|
|
4
|
+
// EVERY VALUE HERE IS A BUCKET, A CATEGORY OR A SMALL COUNT. How long this run
|
|
5
|
+
// took to start, the usage day's peak memory and how many events the deck took
|
|
6
|
+
// in that day leave as the bucket they fall in ("2s", "512m", "1k"); the Claude
|
|
7
|
+
// and Codex plans as the plan's category ("max-20x", "plus") and nothing else
|
|
8
|
+
// from the credentials they are read from; the Claude accounts and the paired
|
|
9
|
+
// machines as counts, capped. The lists below are the only words that can
|
|
10
|
+
// leave, and the API keeps the same ones (ccdeck-api AppEventRequest): a value
|
|
11
|
+
// off a list is not sent.
|
|
12
|
+
//
|
|
13
|
+
// Each bucket is named by its upper edge: "2s" is over one second and up to
|
|
14
|
+
// two, "512m" over 256 MB and up to 512 MB, "1k" a day of 100 to 999 events.
|
|
15
|
+
|
|
16
|
+
export const LAUNCH_BUCKETS = Object.freeze(["1s", "2s", "5s", "10s", "30s", "later"]);
|
|
17
|
+
const LAUNCH_EDGES_MS = [1000, 2000, 5000, 10_000, 30_000];
|
|
18
|
+
|
|
19
|
+
export const MEMORY_BUCKETS = Object.freeze(["128m", "256m", "512m", "1g", "2g", "more"]);
|
|
20
|
+
const MEMORY_EDGES_MB = [128, 256, 512, 1024, 2048];
|
|
21
|
+
|
|
22
|
+
export const EVENT_BUCKETS = Object.freeze(["0", "100", "1k", "10k", "100k", "more"]);
|
|
23
|
+
const EVENT_EDGES = [0, 99, 999, 9999, 99_999];
|
|
24
|
+
|
|
25
|
+
export const CLAUDE_PLANS = Object.freeze(["free", "pro", "max", "max-5x", "max-20x", "team", "enterprise", "api"]);
|
|
26
|
+
export const CODEX_PLANS = Object.freeze(["free", "go", "plus", "pro", "pro-lite", "team", "business", "enterprise", "edu", "api"]);
|
|
27
|
+
|
|
28
|
+
/** How a new version arrived: the deck's own update (a press, or the update it
|
|
29
|
+
* runs while nobody is looking), the desktop app's updater, npx fetching the
|
|
30
|
+
* latest, npm run by hand, or a source checkout pulled. */
|
|
31
|
+
export const UPDATE_VIAS = Object.freeze(["self-update", "desktop-updater", "npx", "npm", "checkout"]);
|
|
32
|
+
|
|
33
|
+
/** The most accounts or paired machines a report says; past it, it says this. */
|
|
34
|
+
export const COUNT_CAP = 50;
|
|
35
|
+
|
|
36
|
+
/** How long the deck's own update stays the answer: a press that failed is not
|
|
37
|
+
* credited with an update somebody made by hand days later. */
|
|
38
|
+
export const SELF_UPDATE_FRESH_MS = 2 * 24 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
function bucketOf(value, edges, names) {
|
|
41
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
|
|
42
|
+
const i = edges.findIndex(edge => value <= edge);
|
|
43
|
+
return names[i < 0 ? names.length - 1 : i];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A run's start, in ms from its process starting to the deck listening. */
|
|
47
|
+
export function launchBucket(ms) {
|
|
48
|
+
return bucketOf(ms, LAUNCH_EDGES_MS, LAUNCH_BUCKETS);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The deck's peak resident memory on a day, in MB. */
|
|
52
|
+
export function memoryBucket(mb) {
|
|
53
|
+
return bucketOf(mb, MEMORY_EDGES_MB, MEMORY_BUCKETS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The live events a day took in. */
|
|
57
|
+
export function eventsBucket(n) {
|
|
58
|
+
return Number.isInteger(n) ? bucketOf(n, EVENT_EDGES, EVENT_BUCKETS) : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A whole count of zero or more, capped at COUNT_CAP; anything else, nothing. */
|
|
62
|
+
export function cappedCount(n) {
|
|
63
|
+
return Number.isInteger(n) && n >= 0 ? Math.min(n, COUNT_CAP) : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The Claude plan as a category, from the two fields Claude Code keeps beside
|
|
68
|
+
* its OAuth token: `subscriptionType` ("pro", "max", "team"…) and, for Max, the
|
|
69
|
+
* `rateLimitTier` that says which of the two ("…max_5x", "…max_20x"). Nothing
|
|
70
|
+
* else in that file is looked at, and a type off the list says nothing.
|
|
71
|
+
*/
|
|
72
|
+
export function claudePlanToken({ subscriptionType, rateLimitTier } = {}) {
|
|
73
|
+
const type = typeof subscriptionType === "string" ? subscriptionType.trim().toLowerCase() : "";
|
|
74
|
+
if (type === "max") {
|
|
75
|
+
const tier = typeof rateLimitTier === "string" ? rateLimitTier.toLowerCase() : "";
|
|
76
|
+
return tier.includes("20x") ? "max-20x" : tier.includes("5x") ? "max-5x" : "max";
|
|
77
|
+
}
|
|
78
|
+
return ["free", "pro", "team", "enterprise"].includes(type) ? type : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The Codex plan as a category, from the `chatgpt_plan_type` claim codex-auth
|
|
82
|
+
* reads; OpenAI writes Pro Lite three ways. */
|
|
83
|
+
export function codexPlanToken(planType) {
|
|
84
|
+
const type = typeof planType === "string" ? planType.trim().toLowerCase().replace(/_/g, "-") : "";
|
|
85
|
+
const plan = type === "prolite" ? "pro-lite" : type;
|
|
86
|
+
return CODEX_PLANS.includes(plan) && plan !== "api" ? plan : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How the version this run is on arrived, for the "update" that says it.
|
|
91
|
+
*
|
|
92
|
+
* `marker` is what the deck wrote when it started an update itself
|
|
93
|
+
* (`{ from, at }`, reports.mjs noteSelfUpdate): it counts only for an update
|
|
94
|
+
* away from the version it was written on, and only while fresh. Otherwise the
|
|
95
|
+
* channel says it: the desktop app updates through its own updater, a source
|
|
96
|
+
* checkout through git, and an npm install either because npx fetched the
|
|
97
|
+
* latest or because somebody ran npm.
|
|
98
|
+
*/
|
|
99
|
+
export function updateVia({ marker, lastVersion, channel, npx = false, now = new Date() } = {}) {
|
|
100
|
+
const at = marker && typeof marker.at === "string" ? Date.parse(marker.at) : NaN;
|
|
101
|
+
if (marker?.from && marker.from === lastVersion && Number.isFinite(at)
|
|
102
|
+
&& now.getTime() - at >= 0 && now.getTime() - at <= SELF_UPDATE_FRESH_MS) {
|
|
103
|
+
return "self-update";
|
|
104
|
+
}
|
|
105
|
+
if (channel === "desktop") return "desktop-updater";
|
|
106
|
+
if (channel === "checkout") return "checkout";
|
|
107
|
+
if (channel === "npm") return npx ? "npx" : "npm";
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── the deck's own readings ──────────────────────────────────────────────────
|
|
112
|
+
//
|
|
113
|
+
// Read only when an "active" is about to go, never on a hot path, and each by
|
|
114
|
+
// lazy import, so the reporter's module graph pulls in none of it. Each one is
|
|
115
|
+
// on its own: a reading that fails or does not apply is left out, and none of
|
|
116
|
+
// them can throw.
|
|
117
|
+
|
|
118
|
+
/** The Claude plan, unless the boot left Claude out. Bedrock, Vertex and an
|
|
119
|
+
* API key are "api"; a machine that keeps its login in the macOS Keychain has
|
|
120
|
+
* no file to read, and says nothing. Bedrock, Vertex and the key are read
|
|
121
|
+
* where the quota reader reads them — Claude Code's settings as well as this
|
|
122
|
+
* deck's environment, "true", "yes" and "on" as well as "1" — so the plan
|
|
123
|
+
* reported and the quota panel agree about one install. */
|
|
124
|
+
async function claudePlan(env) {
|
|
125
|
+
const [{ readFile }, { claudeSignIn, credentialsPath }] = await Promise.all([import("node:fs/promises"), import("./quota-oauth.mjs")]);
|
|
126
|
+
const { cloud, apiKey } = await claudeSignIn(env);
|
|
127
|
+
if (cloud) return "api";
|
|
128
|
+
let oauth = null;
|
|
129
|
+
try {
|
|
130
|
+
oauth = JSON.parse(await readFile(credentialsPath(), "utf8"))?.claudeAiOauth ?? null;
|
|
131
|
+
} catch { /* no file, or not one we can read */ }
|
|
132
|
+
if (oauth) return claudePlanToken(oauth);
|
|
133
|
+
return apiKey ? "api" : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The Codex plan, never refreshing a token to learn it. */
|
|
137
|
+
async function codexPlan() {
|
|
138
|
+
const { getCodexAuth } = await import("./codex-auth.mjs");
|
|
139
|
+
const auth = await getCodexAuth({ allowRefresh: false });
|
|
140
|
+
if (auth?.apiKeyMode) return "api";
|
|
141
|
+
return auth?.ok ? codexPlanToken(auth.planType) : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The accounts the last roster read held: no read is started for this. */
|
|
145
|
+
async function accountCount() {
|
|
146
|
+
const { knownAccountCount } = await import("./claude-accounts.mjs");
|
|
147
|
+
return knownAccountCount();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function quietly(read) {
|
|
151
|
+
try {
|
|
152
|
+
return await read();
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What the deck says about itself on its "active": the plans, the accounts and
|
|
160
|
+
* the paired machines. `setup` is what the boot set up (activation.mjs) — the
|
|
161
|
+
* Claude plan is read unless the Claude hooks are off, the Codex plan only when
|
|
162
|
+
* the deck watches Codex — and `prefs` the held prefs, whose paired decks are
|
|
163
|
+
* the machines.
|
|
164
|
+
*/
|
|
165
|
+
export async function deckDepth({ setup = {}, prefs = null, env = process.env } = {}) {
|
|
166
|
+
const [claude, codex, accounts] = await Promise.all([
|
|
167
|
+
setup.claudeHooks === "off" ? undefined : quietly(() => claudePlan(env)),
|
|
168
|
+
setup.codexWatch === "on" ? quietly(codexPlan) : undefined,
|
|
169
|
+
quietly(accountCount),
|
|
170
|
+
]);
|
|
171
|
+
const trusted = prefs?.lan?.trusted;
|
|
172
|
+
return {
|
|
173
|
+
claudePlan: claude,
|
|
174
|
+
codexPlan: codex,
|
|
175
|
+
accounts,
|
|
176
|
+
machines: Array.isArray(trusted) ? trusted.length : undefined,
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/server/detach.mjs
CHANGED
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
// the terminal is fed by TAILING that file rather than by holding a pipe open.
|
|
21
21
|
// One sink, no handover, nothing to break when the launcher goes.
|
|
22
22
|
//
|
|
23
|
-
// That file is `deck.log`,
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
23
|
+
// That file is `deck.log`, every writer appends to it, and it is started afresh
|
|
24
|
+
// only when no deck is registered or booting on this machine — by moving the
|
|
25
|
+
// old one aside, never by truncating it. A running deck holds an open
|
|
26
|
+
// descriptor into it, so truncating underneath one would punch a hole in the
|
|
27
|
+
// log of a deck nobody asked to disturb — and the case that reaches here with a
|
|
28
|
+
// deck already up is the attach, which has six lines to say and no business
|
|
29
|
+
// erasing anything.
|
|
28
30
|
//
|
|
29
31
|
// ONE PLATFORM WHERE THIS IS NOT ENOUGH, and it is worth knowing before someone
|
|
30
32
|
// reports it as a bug. Windows OpenSSH puts a session's processes in a job
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
// deck was started from, which is what makes `--logs` worth reading, and the
|
|
46
48
|
// launcher passes nothing at all when it is not itself a terminal.
|
|
47
49
|
import { spawn } from "node:child_process";
|
|
48
|
-
import { closeSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
|
|
50
|
+
import { closeSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
|
|
49
51
|
import { join } from "node:path";
|
|
50
52
|
import { isOneShot } from "./args.mjs";
|
|
51
53
|
|
|
@@ -60,16 +62,23 @@ export const DETACHED_ENV = "AGENTS_DECK_DETACHED";
|
|
|
60
62
|
export const DECK_LOG = "deck.log";
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
|
-
*
|
|
65
|
+
* Start this log afresh, or carry on in it?
|
|
64
66
|
*
|
|
65
|
-
*
|
|
66
|
-
* only moment the previous contents are certainly
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
67
|
+
* Afresh when nothing is registered and no deck holds the boot lock, which is
|
|
68
|
+
* the ordinary start and the only moment the previous contents are certainly
|
|
69
|
+
* nobody's. A deck in its boot window is not registered yet — it registers up
|
|
70
|
+
* to eight seconds later (boot-lock.mjs) — so two starts inside one window both
|
|
71
|
+
* read an empty registry; the lock is what the second one can see.
|
|
72
|
+
*
|
|
73
|
+
* AFRESH IS NEVER A TRUNCATION. A deck holds an open descriptor into this file,
|
|
74
|
+
* and truncating under it does not make it start again at zero: its next write
|
|
75
|
+
* lands past the end and leaves a hole. So the old log is moved aside to
|
|
76
|
+
* `deck.log.1` — a deck still writing to it keeps writing there — and a new
|
|
77
|
+
* file is opened. And every writer appends (see detachAndWatch), so nobody's
|
|
78
|
+
* offset can fall behind anybody else's.
|
|
70
79
|
*/
|
|
71
|
-
export function
|
|
72
|
-
return liveCount
|
|
80
|
+
export function startsFresh(liveCount, booting = false) {
|
|
81
|
+
return liveCount === 0 && !booting;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/**
|
|
@@ -152,31 +161,43 @@ export function noConsoleOptions({ detached = false, platform = process.platform
|
|
|
152
161
|
* An npx run has no `ccdeck` on PATH — that is the whole point of npx — so
|
|
153
162
|
* telling one to run `ccdeck --stop` is telling them to run something that does
|
|
154
163
|
* not exist on their machine. `invokedAs` already answers which of the three
|
|
155
|
-
* published names was typed; this only decides
|
|
164
|
+
* published names was typed; this only decides which runner goes in front.
|
|
165
|
+
* `runner` is install-layout's oneOffRunner — "npx", "pnpm dlx", "bunx" or
|
|
166
|
+
* "yarn dlx", none of which leaves the command on PATH either — and `npx` the
|
|
167
|
+
* older spelling of the first.
|
|
156
168
|
*/
|
|
157
|
-
export function stopCommand({ npx = false, invokedAs = null, product = "ccdeck" } = {}) {
|
|
169
|
+
export function stopCommand({ runner = null, npx = false, invokedAs = null, product = "ccdeck" } = {}) {
|
|
170
|
+
return command("--stop", { runner, npx, invokedAs, product });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `<runner> <name> <flag>`, or `<name> <flag>` for a copy that stays. */
|
|
174
|
+
function command(flag, { runner = null, npx = false, invokedAs = null, product = "ccdeck" }) {
|
|
175
|
+
const via = runner || (npx ? "npx" : null);
|
|
158
176
|
const name = invokedAs || product;
|
|
159
|
-
return
|
|
177
|
+
return via ? `${via} ${name} ${flag}` : `${name} ${flag}`;
|
|
160
178
|
}
|
|
161
179
|
|
|
162
180
|
/**
|
|
163
181
|
* What the terminal is left with once the deck has gone to the background: the
|
|
164
|
-
* command that ends it, and — for
|
|
182
|
+
* command that ends it, and — for a one-off run — the one thing it cannot have.
|
|
165
183
|
*
|
|
166
|
-
* THE ONE THING
|
|
167
|
-
* must name a path that will still be there tomorrow, and npx
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
184
|
+
* THE ONE THING A ONE-OFF RUN CANNOT HAVE, said where it is missing. A login
|
|
185
|
+
* item must name a path that will still be there tomorrow, and npx, pnpm dlx,
|
|
186
|
+
* bunx and yarn dlx all run out of a cache or a temp folder that is deleted
|
|
187
|
+
* whenever its owner likes — so such a deck runs in the background and cannot
|
|
188
|
+
* come back after a reboot. One line, no disk written, offered rather than
|
|
189
|
+
* done: `npx` means "run without installing", and a tool that installs itself
|
|
190
|
+
* anyway is the one people uninstall. Through the same runner, like the stop
|
|
191
|
+
* command: neither is on PATH after a one-off run.
|
|
172
192
|
*
|
|
173
193
|
* `tone` is a palette and `g` a glyph tier, both from term.mjs, so a pipe gets
|
|
174
194
|
* no escapes and a console without the em dash gets its stand-in.
|
|
175
195
|
*/
|
|
176
|
-
export function backgroundNote({ npx = false, invokedAs = null, product = "ccdeck", tone, g }) {
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
196
|
+
export function backgroundNote({ runner = null, npx = false, invokedAs = null, product = "ccdeck", tone, g }) {
|
|
197
|
+
const how = { runner, npx, invokedAs, product };
|
|
198
|
+
const stop = stopCommand(how);
|
|
199
|
+
const offer = runner || npx
|
|
200
|
+
? ` ${tone.muted}\`${command("--install", how)}\` also starts it at login${tone.reset}\n`
|
|
180
201
|
: "";
|
|
181
202
|
return ` ${tone.muted}${g.dash} running in the background ${g.bullet} \`${stop}\` ends it${tone.reset}\n${offer}\n`;
|
|
182
203
|
}
|
|
@@ -241,6 +262,7 @@ export async function detachAndWatch({
|
|
|
241
262
|
argv = [],
|
|
242
263
|
logDir,
|
|
243
264
|
liveCount = 0,
|
|
265
|
+
booting = false,
|
|
244
266
|
env = process.env,
|
|
245
267
|
out = process.stdout,
|
|
246
268
|
isTTY = false,
|
|
@@ -254,14 +276,20 @@ export async function detachAndWatch({
|
|
|
254
276
|
const path = join(logDir, DECK_LOG);
|
|
255
277
|
try { mkdirSync(logDir, { recursive: true }); } catch { /* reported by the open below */ }
|
|
256
278
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
279
|
+
if (startsFresh(liveCount, booting)) {
|
|
280
|
+
try { renameSync(path, `${path}.1`); } catch { /* no log yet, or held where it cannot move: carry on in it */ }
|
|
281
|
+
}
|
|
282
|
+
// Where the terminal starts reading: the end of what is there. An attach
|
|
283
|
+
// leaves the running deck's log alone and shows only what this child writes.
|
|
284
|
+
const from = (() => { try { return statSync(path).size; } catch { return 0; } })();
|
|
261
285
|
|
|
262
286
|
let fd;
|
|
263
287
|
try {
|
|
264
|
-
|
|
288
|
+
// "a", O_APPEND, for every start. The supervisor and worker share this
|
|
289
|
+
// descriptor, an attach appends its own lines to the same file, and a login
|
|
290
|
+
// item's service manager appends to it too; opened at an offset of its own,
|
|
291
|
+
// the running deck's next write landed over whatever came after it.
|
|
292
|
+
fd = openSync(path, "a", 0o600);
|
|
265
293
|
} catch (err) {
|
|
266
294
|
// A log we cannot open is not a reason to refuse to start. Say so once and
|
|
267
295
|
// run in the foreground, which is exactly what every version before this
|