ccdeck 3.43.4 → 3.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 +79 -18
- package/THIRD_PARTY_NOTICES.md +70 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +16 -1
- package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
- package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +52 -0
- package/src/server/account-notify.mjs +268 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +107 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +127 -16
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +26 -5
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +23 -3
- package/src/server/installer.mjs +149 -8
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/lan-engine.mjs +2 -2
- package/src/server/lan-sync.mjs +3 -3
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +8 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +26 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/usage-day.mjs +3 -1
- package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
- package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
- package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
- package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
- package/dist/web/assets/app-BiJ8TfUl.css +0 -1
- package/dist/web/assets/app-DBsNB-2y.js +0 -312
|
@@ -28,7 +28,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
28
28
|
import { renameWithRetry } from "./atomic-write.mjs";
|
|
29
29
|
import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
|
|
30
30
|
import { accountKey } from "./lan-sync.mjs";
|
|
31
|
-
import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
|
|
31
|
+
import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap, swapProjectsRoot } from "./swap-log.mjs";
|
|
32
32
|
// The rules the live transcript scan counts a line's usage by, so this rollup
|
|
33
33
|
// and a session's card cannot disagree about which lines billed anything, nor
|
|
34
34
|
// about the speed they were billed at — see transcript-scan.mjs.
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
* a per-account total is never quietly inflated by work that is not that
|
|
44
44
|
* account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
|
|
45
45
|
export const UNATTRIBUTED = "\u0000unattributed";
|
|
46
|
-
const STATE_VERSION =
|
|
46
|
+
const STATE_VERSION = 8;
|
|
47
47
|
|
|
48
48
|
/** Where the tally and cursors live, beside cswap-auto's own state. */
|
|
49
49
|
export function statePath(home = homedir()) {
|
|
@@ -372,11 +372,11 @@ async function listTranscripts(roots) {
|
|
|
372
372
|
if (seen.has(path)) continue;
|
|
373
373
|
seen.add(path);
|
|
374
374
|
const parts = relative(root, dir).split(/[\\/]/).filter(Boolean);
|
|
375
|
-
found.push({ path, slug: parts[0] ?? "", depth: parts.length });
|
|
375
|
+
found.push({ root, path, slug: parts[0] ?? "", depth: parts.length });
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
found.sort((a, b) => a.depth - b.depth);
|
|
379
|
-
return new Map(found.map(f => [f.path, f
|
|
379
|
+
return new Map(found.map(f => [f.path, f]));
|
|
380
380
|
}
|
|
381
381
|
|
|
382
382
|
/** Drop day buckets older than the retention window and prune the empty
|
|
@@ -494,6 +494,8 @@ export function createProjectRollup({
|
|
|
494
494
|
clearInterval: clearIv = clearInterval,
|
|
495
495
|
} = {}) {
|
|
496
496
|
let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {} };
|
|
497
|
+
let canonicalRoots = [];
|
|
498
|
+
let gapRoots = [];
|
|
497
499
|
let timer = null;
|
|
498
500
|
let running = false;
|
|
499
501
|
let dirty = false;
|
|
@@ -502,20 +504,29 @@ export function createProjectRollup({
|
|
|
502
504
|
async function load() {
|
|
503
505
|
if (loaded) return;
|
|
504
506
|
loaded = true;
|
|
507
|
+
canonicalRoots = [...new Set(await Promise.all(roots.map(r => swapProjectsRoot(r))))];
|
|
508
|
+
gapRoots = canonicalRoots;
|
|
505
509
|
try {
|
|
506
510
|
const disk = JSON.parse(await readFile(stateFile, "utf8"));
|
|
511
|
+
gapRoots = [...new Set([...canonicalRoots, ...(Array.isArray(disk?.sourceRoots) ? disk.sourceRoots.filter(r => typeof r === "string") : [])])];
|
|
507
512
|
if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
|
|
508
513
|
state = disk;
|
|
509
514
|
state.folders ??= {};
|
|
510
515
|
state.requests ??= {};
|
|
511
|
-
|
|
516
|
+
if (JSON.stringify(disk.sourceRoots) !== JSON.stringify(canonicalRoots)) {
|
|
517
|
+
state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {},
|
|
518
|
+
lastAlive: disk.lastAlive };
|
|
519
|
+
dirty = true;
|
|
520
|
+
}
|
|
521
|
+
} else if (disk && [1, 2, 3, 4, 5, 6, 7].includes(disk.version)) {
|
|
512
522
|
// Version 1 counted every API content block; version 2 keyed each row
|
|
513
523
|
// by the line's own cwd, a row per folder the agent cd'd into (#1278);
|
|
514
524
|
// version 3 kept no speed, so every fast turn read at the standard
|
|
515
525
|
// rate; version 4 billed a subagent's request from its streaming
|
|
516
526
|
// snapshot, most of its output and its speed missing (#1883); version
|
|
517
527
|
// 5 billed a resumed subagent's request again each time its file
|
|
518
|
-
// restated its history; version 6 kept no per-request context tiers
|
|
528
|
+
// restated its history; version 6 kept no per-request context tiers;
|
|
529
|
+
// version 7 charged independent transcript roots to the same account.
|
|
519
530
|
// None of them can be re-keyed, since it no
|
|
520
531
|
// longer knows which transcript a count came from. Keep the heartbeat
|
|
521
532
|
// so a restart gap remains fenced, but rebuild the tally from
|
|
@@ -524,6 +535,27 @@ export function createProjectRollup({
|
|
|
524
535
|
dirty = true;
|
|
525
536
|
}
|
|
526
537
|
} catch { /* first run: empty state */ }
|
|
538
|
+
state.sourceRoots = canonicalRoots;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The swap-log entries that speak for any of `scope`, a list of canonical
|
|
542
|
+
* roots. An entry written before swaps recorded their projects root has
|
|
543
|
+
* none, and cannot prove which config directory it controlled — unless
|
|
544
|
+
* only one is known: a single configured root (two configured paths are two
|
|
545
|
+
* candidates, even when they resolve to one directory today), and no other
|
|
546
|
+
* root anywhere in the log. Then it can only have spoken for that root, and
|
|
547
|
+
* its history stays the account's, as it was before the upgrade. Otherwise
|
|
548
|
+
* it is left out, and the usage it covered stays unattributed. The log is
|
|
549
|
+
* the record asked because it only grows, so the answer cannot change back
|
|
550
|
+
* from one boot to the next. */
|
|
551
|
+
function timelineFor(timeline, scope) {
|
|
552
|
+
let legacyRoot = null;
|
|
553
|
+
if (new Set(roots.map(r => resolve(r))).size === 1) {
|
|
554
|
+
const known = new Set(canonicalRoots);
|
|
555
|
+
for (const e of timeline) if (e.projectsRoot !== undefined) known.add(e.projectsRoot);
|
|
556
|
+
if (known.size === 1) legacyRoot = canonicalRoots[0];
|
|
557
|
+
}
|
|
558
|
+
return timeline.filter(e => scope.includes(e.projectsRoot === undefined ? legacyRoot : e.projectsRoot));
|
|
527
559
|
}
|
|
528
560
|
|
|
529
561
|
async function persist() {
|
|
@@ -541,7 +573,7 @@ export function createProjectRollup({
|
|
|
541
573
|
} catch { /* a failed persist just re-folds next time; cursors stay in memory */ }
|
|
542
574
|
}
|
|
543
575
|
|
|
544
|
-
async function foldFile(path, slug, timeline) {
|
|
576
|
+
async function foldFile(path, slug, timeline, root) {
|
|
545
577
|
let st = null;
|
|
546
578
|
try { st = await stat(path); } catch { return; }
|
|
547
579
|
let offset = state.cursors[path] ?? 0;
|
|
@@ -552,7 +584,8 @@ export function createProjectRollup({
|
|
|
552
584
|
// is the rare, safe side to err on.
|
|
553
585
|
if (st.size < offset) { state.cursors[path] = st.size; delete state.requests[path]; dirty = true; return; }
|
|
554
586
|
if (st.size <= offset) return;
|
|
555
|
-
const
|
|
587
|
+
const folders = state.folders[root] ??= {};
|
|
588
|
+
const from = { slug, folders, requests: state.requests, path };
|
|
556
589
|
let fh = null;
|
|
557
590
|
try {
|
|
558
591
|
fh = await open(path, "r");
|
|
@@ -574,19 +607,26 @@ export function createProjectRollup({
|
|
|
574
607
|
// switched accounts by hand while nothing was recording. Written before
|
|
575
608
|
// the seed so the order is stop → start.
|
|
576
609
|
if (state.lastAlive && nowMs - state.lastAlive > GAP_MS) {
|
|
577
|
-
await markGap(state.lastAlive + 1, swapLog);
|
|
610
|
+
for (const root of gapRoots) await markGap(state.lastAlive + 1, swapLog, root);
|
|
578
611
|
}
|
|
579
|
-
await seedActive({ now, path: swapLog, root: storeRoot }); // anchor the current account, once, deduped
|
|
612
|
+
await seedActive({ now, path: swapLog, root: storeRoot, projectsRoot: canonicalRoots[0] }); // anchor the current account, once, deduped
|
|
580
613
|
const timeline = await readSwapLog(swapLog);
|
|
581
|
-
const files = await listTranscripts(
|
|
582
|
-
|
|
614
|
+
const files = await listTranscripts(canonicalRoots);
|
|
615
|
+
// Each root is attributed only by the entries that speak for it; an
|
|
616
|
+
// unscoped legacy entry speaks for none unless its root is unambiguous
|
|
617
|
+
// (timelineFor).
|
|
618
|
+
const timelines = new Map(canonicalRoots.map(root => [root, timelineFor(timeline, [root])]));
|
|
619
|
+
for (const [path, { slug, root }] of files) await foldFile(path, slug, timelines.get(root), root);
|
|
583
620
|
// Forget the cursors and request ledgers of files that are gone, and
|
|
584
621
|
// the folders of project directories with no transcript left, so none of
|
|
585
622
|
// the three maps grows forever.
|
|
586
623
|
for (const p of Object.keys(state.cursors)) if (!files.has(p)) { delete state.cursors[p]; dirty = true; }
|
|
587
624
|
for (const p of Object.keys(state.requests)) if (!files.has(p)) { delete state.requests[p]; dirty = true; }
|
|
588
|
-
const
|
|
589
|
-
|
|
625
|
+
for (const root of Object.keys(state.folders)) {
|
|
626
|
+
const slugs = new Set([...files.values()].filter(f => f.root === root).map(f => f.slug));
|
|
627
|
+
for (const s of Object.keys(state.folders[root])) if (!slugs.has(s)) { delete state.folders[root][s]; dirty = true; }
|
|
628
|
+
if (!slugs.size) delete state.folders[root];
|
|
629
|
+
}
|
|
590
630
|
// Heartbeat: record that the deck was alive now, so a later boot can see
|
|
591
631
|
// how long it was down. Cheap when nothing else changed — only bumped
|
|
592
632
|
// (and so only persisted) once every HEARTBEAT_MS while idle.
|
|
@@ -607,7 +647,7 @@ export function createProjectRollup({
|
|
|
607
647
|
async report(key, days) {
|
|
608
648
|
await load();
|
|
609
649
|
const timeline = await readSwapLog(swapLog);
|
|
610
|
-
return reportFrom(state.tally, timeline, key, days, now());
|
|
650
|
+
return reportFrom(state.tally, timelineFor(timeline, canonicalRoots), key, days, now());
|
|
611
651
|
},
|
|
612
652
|
_state: () => state,
|
|
613
653
|
};
|
|
@@ -25,6 +25,9 @@ import { readBody, send } from "./http-io.mjs";
|
|
|
25
25
|
import { heldPrefs } from "./prefs-state.mjs";
|
|
26
26
|
import { PRODUCT } from "./brand.mjs";
|
|
27
27
|
import { incidentsFrom, withDismissed, withSignIn, withTidied } from "./account-origins.mjs";
|
|
28
|
+
// What a removal of the active account does after it lands (#2184). Pure and
|
|
29
|
+
// importless, so it puts nothing on the path to a listening socket.
|
|
30
|
+
import { removeLeavingLiveLogin } from "./login-guard.mjs";
|
|
28
31
|
|
|
29
32
|
// Resolved the way pinned-build.mjs resolves it, from a file in the same
|
|
30
33
|
// directory, so every lazy import below is the URL the pin has already evaluated.
|
|
@@ -52,7 +55,18 @@ export async function handleClaudeAccountSwitch(req, res) {
|
|
|
52
55
|
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
53
56
|
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
send(res, ...await switchAndTell(parsed.account, { switchClaudeAccount, invalidateClaudeAccountsCache }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One switch somebody asked for, and everything that has to hear about it —
|
|
63
|
+
* as `[status, body]`. The switch route's, and the one a removal of an expired
|
|
64
|
+
* active login makes afterwards (removeLeavingLiveLogin), so both say it the
|
|
65
|
+
* same way. The two functions it needs from claude-accounts.mjs are the
|
|
66
|
+
* caller's, which has already imported it.
|
|
67
|
+
*/
|
|
68
|
+
async function switchAndTell(account, { switchClaudeAccount, invalidateClaudeAccountsCache }) {
|
|
69
|
+
const result = await switchClaudeAccount(account);
|
|
56
70
|
// The active account just moved; the next poll should see it immediately
|
|
57
71
|
// rather than serving the pre-switch roster for another few seconds.
|
|
58
72
|
invalidateClaudeAccountsCache();
|
|
@@ -73,10 +87,10 @@ export async function handleClaudeAccountSwitch(req, res) {
|
|
|
73
87
|
// effort — a failed log write must never fail the switch — so it is not
|
|
74
88
|
// awaited into the response.
|
|
75
89
|
import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href)
|
|
76
|
-
.then(({ recordSwap }) => recordSwap(
|
|
90
|
+
.then(({ recordSwap }) => recordSwap(account, "manual"))
|
|
77
91
|
.catch(() => {});
|
|
78
92
|
}
|
|
79
|
-
|
|
93
|
+
return [result.ok ? 200 : 400, result];
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
// The account-projects rollup, started once and shared. Its timer is unref'd
|
|
@@ -256,6 +270,33 @@ async function dismissReauth(raw) {
|
|
|
256
270
|
return { ok: true };
|
|
257
271
|
}
|
|
258
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Remove an account — and when it was the active one and its login had expired,
|
|
275
|
+
* move Claude Code to a working account once it is gone (#2184).
|
|
276
|
+
*
|
|
277
|
+
* `cswap remove` leaves the live login where it is. That is right when the
|
|
278
|
+
* login works and wrong when it is the dead one: every session stays stopped,
|
|
279
|
+
* and nothing else will move them, since auto-switch does not touch a login
|
|
280
|
+
* claude-swap no longer manages. The roster is read BEFORE the removal, because
|
|
281
|
+
* after it the account is not on it to ask about; the move goes through the
|
|
282
|
+
* switch route's own path, guard and bookkeeping included. Like Auto-switch's
|
|
283
|
+
* moves, it never lands on an account claude-swap set aside or one a local
|
|
284
|
+
* `cswap run` session is using (slotsHeldBack).
|
|
285
|
+
*/
|
|
286
|
+
async function removeAccount(admin, num) {
|
|
287
|
+
const accounts = await import(
|
|
288
|
+
pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
|
|
289
|
+
);
|
|
290
|
+
return removeLeavingLiveLogin(num, {
|
|
291
|
+
roster: () => accounts.fetchClaudeAccounts({ force: true }),
|
|
292
|
+
remove: n => admin.removeAccount(n),
|
|
293
|
+
switchTo: async n => (await switchAndTell(n, accounts))[1],
|
|
294
|
+
heldBack: async rows => (await import(
|
|
295
|
+
pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto-choice.mjs")).href
|
|
296
|
+
)).slotsHeldBack(rows),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
259
300
|
// Reading the login's progress. The browser polls this while its dialog is
|
|
260
301
|
// open, the same way the upgrade notice polls /api/version.
|
|
261
302
|
export async function handleAccountLoginState(_req, res) {
|
|
@@ -302,7 +343,9 @@ export async function handleClaudeAccountAdmin(req, res) {
|
|
|
302
343
|
result = out?.ok ? { ...out, results: await admin.checkImportResults(out.results) } : out;
|
|
303
344
|
break;
|
|
304
345
|
}
|
|
305
|
-
|
|
346
|
+
// #2184. Removing the ACTIVE account leaves Claude Code signed in as it;
|
|
347
|
+
// when that login is the expired one, a working account takes over after.
|
|
348
|
+
case "remove": result = await removeAccount(admin, parsed.account); break;
|
|
306
349
|
// #721. Re-captures the active slot's credentials in place; see
|
|
307
350
|
// recaptureActive for why this is not a login and cannot become one.
|
|
308
351
|
case "recapture": result = await admin.recaptureActive(); break;
|
|
@@ -21,6 +21,13 @@ import {
|
|
|
21
21
|
} from "./account-notify.mjs";
|
|
22
22
|
import { deckDataDir } from "./deck-home.mjs";
|
|
23
23
|
import { writeFileAtomic } from "./atomic-write.mjs";
|
|
24
|
+
// What a StopFailure says, and the words a notification names a session by.
|
|
25
|
+
import { stopFailureOf, switchCanCure } from "./stop-failure.mjs";
|
|
26
|
+
import { whoOf } from "./block-notify.mjs";
|
|
27
|
+
// Which deck is on what, as Local network last heard it, and the identity rule
|
|
28
|
+
// its keys are made by (#2192).
|
|
29
|
+
import { elsewhereNow } from "./lan-elsewhere.mjs";
|
|
30
|
+
import { accountKey } from "./lan-copies.mjs";
|
|
24
31
|
|
|
25
32
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
26
33
|
|
|
@@ -39,11 +46,24 @@ export function recordsPath(home = deckDataDir()) {
|
|
|
39
46
|
*/
|
|
40
47
|
export const WATCH_MS = 2 * 60_000;
|
|
41
48
|
|
|
49
|
+
/**
|
|
50
|
+
* How often the notifier asks Local network whether the account this deck is
|
|
51
|
+
* on is live on another deck too (#2192). A round is a minute, so asking more
|
|
52
|
+
* often finds nothing new; and with no deck on any account of this one's, the
|
|
53
|
+
* look is the engine's status and nothing else — the store is not read.
|
|
54
|
+
*/
|
|
55
|
+
export const ELSEWHERE_WATCH_MS = 60_000;
|
|
56
|
+
|
|
42
57
|
let _deps = null;
|
|
43
58
|
let _notifier = null;
|
|
44
59
|
let _autoOn = false;
|
|
45
60
|
let _timer = null;
|
|
61
|
+
let _elsewhereTimer = null;
|
|
46
62
|
let _watching = null;
|
|
63
|
+
/** The auto-switch loop's "tick now", handed in by the loop as it loads
|
|
64
|
+
* (cswap-auto-loop.mjs `tickSoon`). Null until then, and on a deck that never
|
|
65
|
+
* loads it — one with no Claude to switch. */
|
|
66
|
+
let _tickSoon = null;
|
|
47
67
|
|
|
48
68
|
/**
|
|
49
69
|
* Hand the notifier its OS call, its product name and its switches. Called
|
|
@@ -75,6 +95,16 @@ function notifier() {
|
|
|
75
95
|
return _notifier;
|
|
76
96
|
}
|
|
77
97
|
|
|
98
|
+
/**
|
|
99
|
+
* For the suite: settles once every record save the notifier has started has
|
|
100
|
+
* finished. A save follows its notice and is an atomic write, whose rename
|
|
101
|
+
* Windows retries while a scanner holds the file, so no fixed pause after a
|
|
102
|
+
* tick is long enough to read the records back.
|
|
103
|
+
*/
|
|
104
|
+
export function accountNotifySettled() {
|
|
105
|
+
return _notifier ? _notifier.settled() : Promise.resolve();
|
|
106
|
+
}
|
|
107
|
+
|
|
78
108
|
/** The deck's auto-switch loop was switched on or off (cswap-auto-loop.mjs). */
|
|
79
109
|
export function noteAutoSwitch(enabled) {
|
|
80
110
|
_autoOn = enabled === true;
|
|
@@ -86,16 +116,55 @@ export function noteAutoSwitch(enabled) {
|
|
|
86
116
|
* notification is raised: a switch somebody pressed never comes through here.
|
|
87
117
|
* After a switch, claude-swap's roster is read for the two accounts' names and
|
|
88
118
|
* keys — the store, which the deck already reads, rather than anything more out
|
|
89
|
-
* of the tick's own output.
|
|
119
|
+
* of the tick's own output. `meta.startedAt` is when the tick began.
|
|
90
120
|
*/
|
|
91
|
-
export function noteAutoTick(result) {
|
|
121
|
+
export function noteAutoTick(result, meta = {}) {
|
|
92
122
|
const n = notifier();
|
|
93
123
|
if (!n || !result) return Promise.resolve();
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
// A dead login with nowhere to go is named too, so its notice can say whose.
|
|
125
|
+
const named = result.switched || result.reason === "login-expired";
|
|
126
|
+
return (named ? Promise.all([storeAccounts(), result.switched ? telemetryReader() : null]) : Promise.resolve([null, null]))
|
|
127
|
+
.then(([accounts, telemetryOf]) => n.tick(result, accounts, { ...meta, telemetryOf }))
|
|
96
128
|
.catch(() => {});
|
|
97
129
|
}
|
|
98
130
|
|
|
131
|
+
/** What each organization's settings were last seen to turn on, as the
|
|
132
|
+
* sentence a swap notice carries (org-telemetry.mjs), or null. */
|
|
133
|
+
async function telemetryReader() {
|
|
134
|
+
try {
|
|
135
|
+
const { knownOrgTelemetry, telemetryClause } = await import(pathToFileURL(join(PKG_ROOT, "src/server/org-telemetry.mjs")).href);
|
|
136
|
+
const records = await knownOrgTelemetry();
|
|
137
|
+
return org => telemetryClause(records?.[org]);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The loop's way to be asked for a tick now. Called once, as it loads. */
|
|
144
|
+
export function connectEarlyTick(fn) {
|
|
145
|
+
_tickSoon = typeof fn === "function" ? fn : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A hook event, as pushEvent takes it in: when it is a turn that stopped on an
|
|
150
|
+
* expired login or a usage limit, the incident is counted for its notification
|
|
151
|
+
* and the auto-switch loop is asked to tick now rather than at its next
|
|
152
|
+
* interval. The loop coalesces the asks and does nothing while it is off, so
|
|
153
|
+
* ten sessions stopping in one second are one tick, and a deck with auto-switch
|
|
154
|
+
* off only counts.
|
|
155
|
+
*
|
|
156
|
+
* Every other event, and every stop that is not one of those two, returns at
|
|
157
|
+
* once — this is on the path every hook event takes. Never throws.
|
|
158
|
+
*/
|
|
159
|
+
export function noteHookStop(raw, at = Date.now()) {
|
|
160
|
+
try {
|
|
161
|
+
const stop = stopFailureOf(raw, at);
|
|
162
|
+
if (!stop || !switchCanCure(stop.kind)) return;
|
|
163
|
+
notifier()?.stopped({ sessionId: raw.session_id, who: whoOf(raw), kind: stop.kind, at });
|
|
164
|
+
_tickSoon?.();
|
|
165
|
+
} catch { /* never the ingest path's problem */ }
|
|
166
|
+
}
|
|
167
|
+
|
|
99
168
|
/** claude-swap's roster, number → `{ email, organizationUuid, alias }`, or null. */
|
|
100
169
|
async function storeAccounts() {
|
|
101
170
|
try {
|
|
@@ -155,6 +224,35 @@ export function watchOnce(providers) {
|
|
|
155
224
|
return _watching;
|
|
156
225
|
}
|
|
157
226
|
|
|
227
|
+
/**
|
|
228
|
+
* One look at whether the account this deck is on is one a paired deck says it
|
|
229
|
+
* is on too, handed to the notifier, which says it once per incident.
|
|
230
|
+
*
|
|
231
|
+
* The store is read only when some deck is on some account, and through the
|
|
232
|
+
* roster's own cache, which the panel and every LAN round already keep warm. A
|
|
233
|
+
* roster that could not be read says nothing and closes nothing: not knowing
|
|
234
|
+
* which account this deck is on is not the same as being on none.
|
|
235
|
+
*/
|
|
236
|
+
export async function watchElsewhereOnce(now = Date.now()) {
|
|
237
|
+
const n = notifier();
|
|
238
|
+
if (!n) return;
|
|
239
|
+
try {
|
|
240
|
+
const on = elsewhereNow(now);
|
|
241
|
+
if (on.size === 0) return n.elsewhere(null, []);
|
|
242
|
+
const { fetchClaudeAccounts } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
|
|
243
|
+
const got = await fetchClaudeAccounts();
|
|
244
|
+
if (!got?.ok || !Array.isArray(got.accounts)) return;
|
|
245
|
+
const live = got.accounts.find(a => a?.active === true);
|
|
246
|
+
if (!live?.email) return n.elsewhere(null, []);
|
|
247
|
+
const key = accountKey(live.email, live.orgUuid);
|
|
248
|
+
const name = typeof live.alias === "string" && live.alias.trim() ? live.alias.trim() : live.email;
|
|
249
|
+
return n.elsewhere({ key, name }, on.get(key) ?? []);
|
|
250
|
+
} catch {
|
|
251
|
+
// The roster's own failures are its panel's to say; here there is nothing
|
|
252
|
+
// to compare this time.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
158
256
|
/**
|
|
159
257
|
* Start reading quota on a timer, from startServer once the port is bound —
|
|
160
258
|
* never at import, because a launcher imports the server module only to ask
|
|
@@ -166,4 +264,9 @@ export function startAccountWatch({ providers } = {}) {
|
|
|
166
264
|
_timer.unref?.();
|
|
167
265
|
const first = setTimeout(() => { watchOnce(providers).catch(() => {}); }, 15_000);
|
|
168
266
|
first.unref?.();
|
|
267
|
+
// And Local network's word on the account this deck is on, on its own clock:
|
|
268
|
+
// it costs nothing while no deck is on anything, and it is said whichever of
|
|
269
|
+
// the quota switches is on.
|
|
270
|
+
_elsewhereTimer = setInterval(() => { watchElsewhereOnce().catch(() => {}); }, ELSEWHERE_WATCH_MS);
|
|
271
|
+
_elsewhereTimer.unref?.();
|
|
169
272
|
}
|
package/src/server/activity.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// With no page open there is no reducer, so this keeps the smallest version of
|
|
11
11
|
// it: a session is open from the first event of a turn until the turn ends
|
|
12
|
-
// (`Stop`, `
|
|
12
|
+
// (`Stop`, `StopFailure` — the Stop of a turn that ended on an API error —
|
|
13
|
+
// `SessionEnd`). SessionStart and Notification move nothing — a CLI
|
|
13
14
|
// sitting at its prompt is not busy, and the idle reminder arrives after the
|
|
14
15
|
// turn it is about. An open session whose last event is older than
|
|
15
16
|
// OPEN_CAP_MS counts as gone rather than busy: a CLI that crashed never sends
|
|
@@ -26,7 +27,7 @@ export function shouldTrackActivity(raw, opts = {}) {
|
|
|
26
27
|
const OPENS = new Set([
|
|
27
28
|
...RECOVERIES, "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop",
|
|
28
29
|
]);
|
|
29
|
-
const CLOSES = new Set(["Stop", "SessionEnd"]);
|
|
30
|
+
const CLOSES = new Set(["Stop", "StopFailure", "SessionEnd"]);
|
|
30
31
|
|
|
31
32
|
/** More turns open at once than any machine runs — the same figure as the
|
|
32
33
|
* deck's own working limit for the sessions it tracks (MAX_TRACKED_SESSIONS in
|
package/src/server/args.mjs
CHANGED
|
@@ -161,17 +161,119 @@ export const ONE_SHOT = Object.freeze([
|
|
|
161
161
|
"purge",
|
|
162
162
|
]);
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The words the deck answers as commands (#2183), by every spelling it takes.
|
|
166
|
+
*
|
|
167
|
+
* Two kinds. A COMMAND is something the flags never did — read the accounts,
|
|
168
|
+
* switch one, list the sessions — and lands in `command`, answered by
|
|
169
|
+
* bin/cli/commands.js. A FLAG WORD is a flag that already existed under a
|
|
170
|
+
* second spelling: `ccdeck stop` is `ccdeck --stop`, set as that very flag, so
|
|
171
|
+
* the one-shot that answers it cannot tell the two apart and there is one
|
|
172
|
+
* implementation of each. `start` is the bare start, spelled out by people who
|
|
173
|
+
* type a verb first, and sets nothing at all.
|
|
174
|
+
*
|
|
175
|
+
* `swap` is a switch here, not claude-swap's "exchange two slots": that is the
|
|
176
|
+
* word the people asking for this used, and a switch is what they meant. Two
|
|
177
|
+
* account operands are answered with where the slot exchange lives instead
|
|
178
|
+
* (see bin/cli/commands.js).
|
|
179
|
+
*/
|
|
180
|
+
export const COMMANDS = Object.freeze({
|
|
181
|
+
accounts: "accounts", account: "accounts", ls: "accounts",
|
|
182
|
+
switch: "switch", swap: "switch", use: "switch",
|
|
183
|
+
usage: "usage", quota: "usage",
|
|
184
|
+
sessions: "sessions", ps: "sessions",
|
|
185
|
+
open: "open",
|
|
186
|
+
});
|
|
187
|
+
const FLAG_WORDS = Object.freeze({
|
|
188
|
+
status: "status", stop: "stop", logs: "logs", help: "help", version: "version",
|
|
189
|
+
});
|
|
190
|
+
const BARE_START = "start";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The flags only a START reads. A line carrying one is a deck being started,
|
|
194
|
+
* whatever else is on it, and its bare words are what they always were —
|
|
195
|
+
* unknown options, named, and the deck starts — rather than commands.
|
|
196
|
+
*
|
|
197
|
+
* Two lines need that and neither can say so any other way. An unquoted path
|
|
198
|
+
* with a space in it: `--workspace /home/ana/Face Swap` is `--workspace
|
|
199
|
+
* /home/ana/Face` and a bare `Swap`, which is a switch to whoever reads words
|
|
200
|
+
* as commands. And the npx relaunch of an upgrade (npxRelaunchArgs in
|
|
201
|
+
* supervisor.mjs), which replays the user's own argv with `--no-open` added —
|
|
202
|
+
* an argv an older deck booted on, where `ccdeck myproj` was a warning and a
|
|
203
|
+
* start, and where a newer parser reading `myproj` as a misspelled command
|
|
204
|
+
* would end the deck it was upgrading.
|
|
205
|
+
*/
|
|
206
|
+
const START_FLAGS = new Set([
|
|
207
|
+
"--no-open", "--new", "--foreground", "--at-login", "--workspace", "--scope",
|
|
208
|
+
"--no-persist", "--history", "--codex", "--no-codex", "--claude", "--no-claude", "--ref",
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Could this token have been meant as a command?
|
|
213
|
+
*
|
|
214
|
+
* A word — letters, digits and dashes, starting with a letter. Anything with a
|
|
215
|
+
* separator, a dot or a tilde in it is a path, and a path in first position is
|
|
216
|
+
* the old mistake `ccdeck ~/proj`, reported the old way: an unknown option, and
|
|
217
|
+
* the deck still starts.
|
|
218
|
+
*/
|
|
219
|
+
function wordShaped(token) {
|
|
220
|
+
return /^[a-z][a-z0-9-]*$/i.test(token);
|
|
221
|
+
}
|
|
222
|
+
|
|
164
223
|
/** Is this a command line that answers and leaves? */
|
|
165
224
|
export function isOneShot(flags = {}) {
|
|
225
|
+
// A command, and a word that was not one, answer and leave too: a command
|
|
226
|
+
// that detached would print its answer into deck.log, and a misspelled one
|
|
227
|
+
// that started a deck would leave a server behind on a machine reached over
|
|
228
|
+
// SSH, where there is no tab to show it in.
|
|
229
|
+
if (typeof flags.command === "string" || typeof flags.unknownCommand === "string") return true;
|
|
166
230
|
return ONE_SHOT.some((k) => flags[k] === true);
|
|
167
231
|
}
|
|
168
232
|
|
|
169
|
-
|
|
233
|
+
/**
|
|
234
|
+
* `commands: false` reads every bare word the way the parser did before there
|
|
235
|
+
* were commands, which is how a RESPAWNED worker reads its argv (bin/deck.js):
|
|
236
|
+
* the line is one a deck already booted on — perhaps an older deck, for which
|
|
237
|
+
* `ccdeck serve` was a warning and a start — and a worker that answered it as
|
|
238
|
+
* a command would exit, and the supervisor would count each exit as a crash.
|
|
239
|
+
*/
|
|
240
|
+
export function parseArgs(args, { commands = true } = {}) {
|
|
170
241
|
const out = { unknown: [], incomplete: [] };
|
|
171
242
|
// END OF OPTIONS. A bare `--` is the POSIX separator, and reporting it as an
|
|
172
|
-
// unknown option warned about a token the user typed on purpose.
|
|
243
|
+
// unknown option warned about a token the user typed on purpose. After a
|
|
244
|
+
// command, what follows it is operands — `ccdeck switch -- -odd-alias` —
|
|
245
|
+
// which is the separator's whole purpose.
|
|
173
246
|
const end = args.indexOf("--");
|
|
174
247
|
const argv = end === -1 ? args : args.slice(0, end);
|
|
248
|
+
const afterEnd = end === -1 ? [] : args.slice(end + 1);
|
|
249
|
+
const words = commands && !argv.some(t => START_FLAGS.has(t.startsWith("--") ? t.split("=")[0] : t));
|
|
250
|
+
// A bare word, sorted by where it stands. Only the FIRST bare word on a line
|
|
251
|
+
// can be a command, and it can be one wherever it stands among the flags, so
|
|
252
|
+
// `ccdeck --json accounts` reads the same as `ccdeck accounts --json`; every
|
|
253
|
+
// bare word after it is one of its operands. A word-shaped token in first
|
|
254
|
+
// position that is not a command is a command misspelled — `ccdeck acounts` —
|
|
255
|
+
// and is refused rather than starting a deck. Any other bare word is what it
|
|
256
|
+
// always was: an unknown option, named, and the deck starts — so is every
|
|
257
|
+
// bare word on a start line (START_FLAGS) and behind a word that was not a
|
|
258
|
+
// command (`ccdeck ~/my swap`).
|
|
259
|
+
let bare = 0;
|
|
260
|
+
const positional = (token, first) => {
|
|
261
|
+
if (out.command) { out.operands.push(token); return; }
|
|
262
|
+
const word = String(token).toLowerCase();
|
|
263
|
+
const lead = bare++ === 0;
|
|
264
|
+
if (lead && word === BARE_START) return;
|
|
265
|
+
if (lead && words) {
|
|
266
|
+
if (Object.hasOwn(COMMANDS, word)) {
|
|
267
|
+
out.command = COMMANDS[word];
|
|
268
|
+
out.commandWord = word;
|
|
269
|
+
out.operands = [];
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (Object.hasOwn(FLAG_WORDS, word)) { out[FLAG_WORDS[word]] = true; return; }
|
|
273
|
+
if (first && wordShaped(token)) { out.unknownCommand = token; return; }
|
|
274
|
+
}
|
|
275
|
+
out.unknown.push(token);
|
|
276
|
+
};
|
|
175
277
|
for (let i = 0; i < argv.length; i++) {
|
|
176
278
|
// `--port=4400`, split here so the dispatch below sees the two halves it
|
|
177
279
|
// already knows how to read.
|
|
@@ -248,7 +350,15 @@ export function parseArgs(args) {
|
|
|
248
350
|
// The ccdeck.dev page the command was copied from, for the first install
|
|
249
351
|
// report (install-ref.mjs). A value that is not a ref is dropped there.
|
|
250
352
|
else if (a === "--ref") set("ref", "a page name");
|
|
353
|
+
// What the commands read (#2183): machine-readable output on stdout, and
|
|
354
|
+
// the account with the most room rather than a named one.
|
|
355
|
+
else if (a === "--json") out.json = true;
|
|
356
|
+
else if (a === "--best") out.best = true;
|
|
357
|
+
else if (!looksLikeFlag(argv[i])) positional(argv[i], i === 0);
|
|
251
358
|
else out.unknown.push(argv[i]); // the token as typed, `=` half included
|
|
252
359
|
}
|
|
360
|
+
for (const token of afterEnd) {
|
|
361
|
+
if (out.command) out.operands.push(token);
|
|
362
|
+
}
|
|
253
363
|
return out;
|
|
254
364
|
}
|