ccdeck 3.22.15 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +244 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -184
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +335 -47
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +472 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BYqkKWPC.js +0 -274
  51. package/dist/web/assets/index-s_dF8YYA.css +0 -1
@@ -5,13 +5,14 @@
5
5
  // sessions reach the same server through the rollout watcher instead, so one
6
6
  // running server still sees both CLIs. Re-runs are safe; entries are tagged
7
7
  // with __agent-dag and de-duped.
8
- import { readFile, mkdir, unlink, rename, open, stat, chmod, realpath, readlink } from "node:fs/promises";
8
+ import { readFile, mkdir, unlink, rename, open, stat, chmod, realpath, readlink, utimes } from "node:fs/promises";
9
9
  import { existsSync } from "node:fs";
10
10
  import { join, resolve, dirname } from "node:path";
11
11
  import { setTimeout as delay } from "node:timers/promises";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { claudeConfigDir } from "./claude-dir.mjs";
14
14
  import { CODEX_HOME } from "./codex-dir.mjs";
15
+ import { canonicalLogPath } from "./log-writer.mjs";
15
16
  import { shellQuoteArg } from "./exec.mjs";
16
17
  import { PRODUCT } from "./brand.mjs";
17
18
 
@@ -416,10 +417,31 @@ async function installHookScript(installDir) {
416
417
  return dst;
417
418
  }
418
419
 
420
+ /**
421
+ * The entry Claude Code and Codex run, and the one number in it that is a
422
+ * promise about time.
423
+ *
424
+ * `timeout` is in SECONDS and it is a kill: when it expires the host CLI takes
425
+ * the hook down where it stands, which on a PreToolUse means the deck is handed
426
+ * a truncated body and the user waits the whole of it before the tool call
427
+ * proceeds. So the declared value has to be strictly larger than the worst the
428
+ * hook can spend on itself, and it was not. hook.js caps itself at CAP_MS =
429
+ * 1900ms, that cap now runs from Node's start rather than from main()'s, and
430
+ * what is still outside it is only the `sh -c` fork/exec — but 1900 + a fork
431
+ * under load does not fit in 2000. Measured through the installed command
432
+ * shape with a deck that stops answering mid-run: 1.84s idle, 1.98-2.19s with
433
+ * the box loaded (#1018).
434
+ *
435
+ * 3 is a backstop, not a budget: the hook ends itself first in every case it
436
+ * can see, and this is what covers the ones it cannot — a payload large enough
437
+ * that parsing it starves the timer, or a filesystem thread that cannot be
438
+ * interrupted at all. hook-budget.test.ts pins this number against hook.js's
439
+ * own cap so raising one without the other fails.
440
+ */
419
441
  function buildHookEntry(command) {
420
442
  return {
421
443
  [MARK_KEY]: true,
422
- hooks: [{ type: "command", command, timeout: 2 }],
444
+ hooks: [{ type: "command", command, timeout: 3 }],
423
445
  };
424
446
  }
425
447
 
@@ -667,6 +689,59 @@ export function hasCodexInstalled() {
667
689
  return existsSync(CODEX_DIR);
668
690
  }
669
691
 
692
+ /**
693
+ * Whether an older deck's Codex forwarders are still in hooks.json, found
694
+ * without touching the file: `{ settingsPath, count }` when they are, null when
695
+ * they are not or when the file cannot be read.
696
+ *
697
+ * #253 stopped installing Codex hooks and #317 deleted the recipe, but a
698
+ * machine that ran a deck from before either still has up to nine marked
699
+ * entries in $CODEX_HOME/hooks.json, each pointing at a copy of the forwarder
700
+ * that is still on disk. Wherever Codex honours that file, every one of them
701
+ * posts to `/api/event` — which takes a post without a token — while the
702
+ * rollout watcher reads the same session off disk, so the session is ingested
703
+ * twice: both copies reach the event ring and events.jsonl, and only the ones
704
+ * that land inside the reducer's redelivery windows are folded on the card.
705
+ * uninstallHooks takes them out, and `--uninstall` is its only caller; nothing
706
+ * else ever looked (#983).
707
+ *
708
+ * READ ONLY, and that is the decision rather than an oversight. This runs on
709
+ * every boot, and a boot writes nothing under the Codex directory — bin/deck.js
710
+ * says so where it decides whether to watch Codex at all. The one retirement a
711
+ * boot does perform on another tool's file, the finish-sound hook above, rides
712
+ * on a write of Claude Code's settings.json that the boot was making anyway,
713
+ * and installHooks says that riding along is what makes it safe: one write,
714
+ * compared against the bytes just read, converging on every later boot. A sweep
715
+ * of hooks.json would have none of that. It would be the deck's first
716
+ * unasked-for write to a file it no longer manages, made on every machine that
717
+ * has Codex, to cure a condition only upgraded machines have. So this finds
718
+ * them, the banner names them with the command that removes them, and the user
719
+ * decides — the shape `--uninstall` already gives the LAN key: named, not
720
+ * removed, unless somebody asked.
721
+ *
722
+ * A file that cannot be read answers null rather than throwing. The boot must
723
+ * not die over a Codex file, and "your hooks are in there" said of a file
724
+ * nobody could parse would be a guess; `--uninstall` reports that case itself,
725
+ * out loud, when it is run.
726
+ */
727
+ export async function leftoverCodexHooks() {
728
+ const settingsPath = PROVIDERS.codex.settingsPath;
729
+ let settings;
730
+ try {
731
+ ({ settings } = await readSettingsForWrite(settingsPath));
732
+ } catch {
733
+ return null;
734
+ }
735
+ const hooks = settings?.hooks;
736
+ if (!hooks || typeof hooks !== "object") return null;
737
+ let count = 0;
738
+ for (const group of Object.values(hooks)) {
739
+ if (!Array.isArray(group)) continue;
740
+ for (const g of group) if (isOurEntry(g)) count++;
741
+ }
742
+ return count > 0 ? { settingsPath, count } : null;
743
+ }
744
+
670
745
  /**
671
746
  * The events log as the record spells it: an absolute path, or null when this
672
747
  * deck writes none. Shared by the writer and by ensureDiscovery's comparison,
@@ -676,6 +751,21 @@ function persistField(persist) {
676
751
  return typeof persist === "string" && persist !== "" ? persist : null;
677
752
  }
678
753
 
754
+ /**
755
+ * The Codex tree as the record spells it: the canonical path of the CODEX_HOME
756
+ * this process reads, or null for a --no-codex deck, which reads none. Shared by
757
+ * the writer and by ensureDiscovery's comparison for the reason persistField
758
+ * is, and exported for a start's own shape in bin/deck.js — so a start asking
759
+ * "does the running deck read my tree" spells the tree exactly the way the
760
+ * record it compares against does. Worked out on every call rather than once at
761
+ * load: it is one realpath on a five-second heartbeat, and a tree that did not
762
+ * exist when the deck started — the first `codex login` creates it — is then
763
+ * named the way it is named once it does.
764
+ */
765
+ export function codexHomeField(codex) {
766
+ return codex !== false ? canonicalLogPath(CODEX_DIR) : null;
767
+ }
768
+
679
769
  // Every deck's token lives in this directory, and writeFileAtomic's temp file is
680
770
  // created beside its target with whatever the umask allows — 0644 on most
681
771
  // machines — so for the moment before the rename the token would sit in a
@@ -731,6 +821,23 @@ export async function writeDiscovery({ port, workspace, token, persist = null, c
731
821
  // than win it and record a rollout it is not even reading. See
732
822
  // writesCodexLog in src/server/log-writer.mjs.
733
823
  codex: codex !== false,
824
+ // And WHOSE rollouts. `codex: true` says this deck tails them and never said
825
+ // from where, while CODEX_HOME moves the whole tree — #375 was five modules
826
+ // disagreeing about where it points. Two decks sharing one events.jsonl can
827
+ // therefore be reading two different trees, and the election grouped them
828
+ // anyway, because an unscoped deck's workspace contains every cwd and this
829
+ // was the one field that could have told them apart. The lower-port deck won
830
+ // a rollout it had never opened, the deck reading it stood down, and the
831
+ // shared log recorded nothing of that session (#982).
832
+ //
833
+ // Canonical rather than as spelled, for the reason canonicalLogPath gives
834
+ // the log path above (#793): a symlinked ~/.codex, an 8.3-shortened
835
+ // USERPROFILE or /tmp against /private/tmp would otherwise put two decks
836
+ // reading one tree into two groups and elect them both — the duplicate the
837
+ // election exists to end. Only this published copy is canonicalised.
838
+ // codex-dir.mjs goes on reading and writing through the spelling the user
839
+ // chose, which it has to for a symlinked home (see codexHome() there).
840
+ codexHome: codexHomeField(codex),
734
841
  // Does this deck run Browser Watch? The watch elects a single writer among
735
842
  // the decks on a machine, and it elected on port alone — so an older ccdeck
736
843
  // that predates the feature won the election by having the lower port and
@@ -791,13 +898,17 @@ export function discoveryPath() {
791
898
  * the deck received zero events while looking perfectly healthy. Re-asserting
792
899
  * is cheap (one small read), so the deck checks rather than assumes.
793
900
  *
794
- * A file this process wrote is left alone, mode included. Anything else — no
795
- * file, unreadable, another pid, a stale port, token, events log, Codex or
796
- * Claude setting, or a version left by the deck this process replaced — is
797
- * replaced. Every field another deck decides by is compared, the
901
+ * A file this process wrote is left alone, mode and contents included — only its
902
+ * mtime moves, stamped on every check; see the note where it is. Anything else —
903
+ * no file, unreadable, another pid, a stale port, token, events log, Codex
904
+ * setting or Codex tree, Claude setting, or a version left by the deck this
905
+ * process replaced — is replaced. Every field another deck decides by is compared, the
798
906
  * log path included: leave one out and a record missing it would pass as ours
799
907
  * forever, which for the log path means no deck can tell which of them share a
800
- * file and they all write their own copy of every event again.
908
+ * file and they all write their own copy of every event again. The Codex tree
909
+ * is compared strictly for the same reason: a record from before the field
910
+ * existed has none, and `undefined` must read as "rewrite", never as a
911
+ * --no-codex deck's null.
801
912
  */
802
913
  export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
803
914
  const file = discoveryPath();
@@ -810,9 +921,19 @@ export async function ensureDiscovery({ port, workspace, token, persist = null,
810
921
  && (d.token ?? "") === (token ?? "")
811
922
  && (d.persist ?? null) === persistField(persist)
812
923
  && d.codex === (codex !== false)
924
+ && d.codexHome === codexHomeField(codex)
813
925
  && d.claude === (claude !== false)
814
926
  && (d.version ?? "") === (typeof version === "string" ? version : "")
815
927
  && (d.parent ?? null) === (Number.isInteger(parent) ? parent : null)) {
928
+ // STAMPED, though nothing in it changed. The mtime is how a reader tells a
929
+ // record some deck is still keeping from one whose deck is gone while its
930
+ // pid lives on under another process: hook.js unlinks a record whose port
931
+ // lets both challenge deadlines pass only once this stamp is a minute old
932
+ // (#1069), which a deck running this check every five seconds never lets
933
+ // happen. Best-effort — a stamp that cannot be written leaves the record
934
+ // exactly as every deck before this one left it.
935
+ const now = new Date();
936
+ await utimes(file, now, now).catch(() => {});
816
937
  return { file, rewritten: false };
817
938
  }
818
939
  } catch { /* missing, unreadable or corrupt — rewritten below */ }
@@ -909,4 +1030,11 @@ export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
909
1030
  // repo for the same reasons settings.json is, and "never rename onto a link" is
910
1031
  // one rule: codex-auth.mjs called a realpath of its own before this existed, and
911
1032
  // two spellings of a rule are two things that can drift.
912
- export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget };
1033
+ // `stripBom` goes out on the same argument at the smallest scale. Two stores
1034
+ // now move an unparseable file aside instead of writing defaults over it —
1035
+ // deck-prefs.mjs since #1002 and browser-watch-store.mjs since #1003 — and all
1036
+ // three readers have to mean the same thing by "a BOM is not damage". If they
1037
+ // do not, a prefs.json or a state.json somebody opened in Notepad gets
1038
+ // quarantined for a mark settings.json has ignored since this function was
1039
+ // written.
1040
+ export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget, stripBom };
@@ -7,8 +7,10 @@
7
7
  // first thing somebody hunting for a known-broken version would want, and
8
8
  // nobody on the network is owed it. It rides the manifest exchange, which only
9
9
  // happens between two decks that have each accepted the other — and it is
10
- // sealed with that connection's key, because the frames around it are JSON in
11
- // the clear on the wire.
10
+ // sealed with that connection's key on its own. Between two decks that both
11
+ // seal (#810) the frame around it is sealed too, and this is a second seal
12
+ // inside the first; toward a deck from before that, the frames around it are
13
+ // still JSON in the clear on the wire, and this seal is the only one there is.
12
14
  //
13
15
  // BOTH WAYS. The deck that dials sends its own card with the question, and the
14
16
  // deck that answers sends its own with the answer. A deck that only calls in —
@@ -85,7 +87,10 @@ export function aboutThisDeck({
85
87
  * `\p{Cc}` is the Unicode class for exactly those, C0 and C1 both. */
86
88
  function field(v, max, shape = null) {
87
89
  if (typeof v !== "string") return null;
88
- const flat = v.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
90
+ // `\p{Cf}` too — the FORMAT class the control class does not cover, holding
91
+ // U+202E and the directional isolates. `os` is free-form and 48 characters
92
+ // wide, so it is the longest peer-chosen string the panel draws.
93
+ const flat = v.replace(/\p{Cc}/gu, " ").replace(/\p{Cf}/gu, "").replace(/\s+/g, " ").trim();
89
94
  const s = [...flat].slice(0, max).join("").trim();
90
95
  return s && (!shape || shape.test(s)) ? s : null;
91
96
  }