ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,239 @@
1
+ // The calling half: dial a deck, prove this one, check that deck's proof back,
2
+ // and hand over the connection — with the sentence each refusal from the far
3
+ // end is read as. Lifted out of lan-socket.mjs, which re-exports it; the
4
+ // answering half stays there, and the line both halves speak is lan-lines.mjs's.
5
+ import net from "node:net";
6
+ import { cleanName } from "./lan-sync.mjs";
7
+ import {
8
+ challengeFor, ephemeralPair, frameChannel, handshakeTranscript, mixesEphemeral, proof, proofOk, readChallenge,
9
+ readEphemeral, readPub, sealsFrames, sessionKey,
10
+ } from "./lan-wire.mjs";
11
+ import { inviteProof, inviteProofBack } from "./lan-invite.mjs";
12
+ import { frameReader, sendFrame, HANDSHAKE_MS } from "./lan-lines.mjs";
13
+
14
+ /**
15
+ * What each refusal reason means, in a sentence the LAN panel can print.
16
+ *
17
+ * At module scope so it is one object rather than one per frame, and named so
18
+ * the `Object.hasOwn` guard below reads as the rule it is rather than as
19
+ * punctuation. The keys are the protocol's, not a user's.
20
+ */
21
+ const REFUSALS = Object.freeze({
22
+ pending: "waiting for the other deck to accept this one",
23
+ declined: "that deck said no",
24
+ "invite only": "that deck pairs only by invite",
25
+ // Sent back to a caller that said it was not asking, so the far end's answer
26
+ // is about the caller's own setting — the same words its own round uses.
27
+ "not asking": "this deck pairs only by invite",
28
+ impostor: "that deck has this one pinned under a different key",
29
+ "wrong invite": "that deck did not take this invite — ask them for a new one",
30
+ "bad proof": "the other deck refused this one's proof",
31
+ });
32
+
33
+ /**
34
+ * The calling half: connect, prove, be proved to, then talk.
35
+ *
36
+ * BOTH SIDES PROVE, and the second half is the one that is easy to skip. A
37
+ * handshake where only the caller proves itself stops a stranger reading a
38
+ * manifest and stops nothing else — a stranger can still stand up a listener on
39
+ * the announced port, wait for a real deck to dial it, and be handed whatever
40
+ * that deck was going to say. So this checks the reply with the same care the
41
+ * server checks the hello, and gives up if it does not hold.
42
+ */
43
+ export function connectToPeer({
44
+ host, port, fp, pub, secret, name, myPort = null, code = null, timeoutMs = HANDSHAKE_MS,
45
+ /** False when this deck is reaching an address it already had WITHOUT asking
46
+ * to pair — an invite-only deck's round. Sent only when false, so every other
47
+ * hello is byte-for-byte what it was, and a deck that predates the field
48
+ * reads past it the way it reads past `epk`. */
49
+ ask = true,
50
+ /** The public key we pinned for this deck the first time, or null for a deck
51
+ * we are meeting — an address somebody typed. */
52
+ expectPub = null,
53
+ /** Whether the deck that minted `code` said it will prove it holds one too.
54
+ * From the token's own `pb`, which is the only way to tell a deck that will
55
+ * not from a deck that cannot — see mintInvite. False leaves this path
56
+ * exactly as it shipped, for a token minted by an older deck. */
57
+ inviteProvesBack = false,
58
+ /** Whether this deck seals every frame after the handshake with a deck that
59
+ * says it does too — see frameChannel. False announces nothing and seals
60
+ * nothing, exactly as every deck before #810 dials; only the suite asks. */
61
+ sealFrames = true,
62
+ /** Whether this deck mixes a key pair made for the connection into its key,
63
+ * with a deck that says it does too — see sessionKey. False dials exactly
64
+ * as a deck of #810's version does; only the suite asks. */
65
+ ephemeral = true,
66
+ }) {
67
+ return new Promise((resolve, reject) => {
68
+ const myChallenge = challengeFor({ seals: sealFrames, ephemeral });
69
+ // Made before the other deck is heard from, because the public half goes
70
+ // in the hello. Let go of once the key is derived — unused, when the deck
71
+ // that answers turns out not to mix — and on every way out below.
72
+ let mine = mixesEphemeral(myChallenge) ? ephemeralPair() : null;
73
+ const myEpk = mine?.pub;
74
+ const sock = net.createConnection({ host, port });
75
+ sock.setEncoding("utf8");
76
+ let settled = false;
77
+ const fail = err => {
78
+ if (settled) return;
79
+ settled = true;
80
+ mine = null;
81
+ sock.destroy();
82
+ reject(err instanceof Error ? err : new Error(String(err)));
83
+ };
84
+ const timer = setTimeout(() => fail(new Error("handshake timed out")), timeoutMs);
85
+ timer.unref?.();
86
+
87
+ sock.on("error", fail);
88
+ sock.on("close", () => fail(new Error("peer closed the connection")));
89
+ sock.on("connect", () => {
90
+ // No proof in the hello: the caller cannot cover a challenge it has not
91
+ // been given, and a proof over an empty one would be a proof that means
92
+ // nothing. It goes in message three.
93
+ // `port` is where WE listen, which is not the port this socket came from
94
+ // — that one is ephemeral and useless to dial. Without it a deck can
95
+ // accept an incoming request and still have no way to reach back, so the
96
+ // pairing is mutual on paper and one-way in fact.
97
+ // `epk` is this connection's key pair, offered: a deck from before #1120
98
+ // reads past a field it does not know, and one of this version answers.
99
+ sendFrame(sock, {
100
+ t: "hello", fp, pub, name, port: myPort, challenge: myChallenge, ...(myEpk ? { epk: myEpk } : {}),
101
+ ...(ask === false ? { ask: false } : {}),
102
+ });
103
+ });
104
+
105
+ let theirChallenge = null;
106
+ let theirFp = null;
107
+ let theirPub = null;
108
+ let key = null;
109
+ /** What the key was derived over; the invite proofs are made over it too. */
110
+ let transcript = null;
111
+
112
+ /** Step 2, answered: the listener's challenge, checked against the pin,
113
+ * and this deck's proof back — with the connection's key derived first. */
114
+ const challenge = msg => {
115
+ if (theirFp || !readChallenge(msg.challenge)) return fail(new Error("bad challenge"));
116
+ const them = readPub(msg.pub);
117
+ if (!them || them.fp !== msg.fp) return fail(new Error("bad challenge"));
118
+ // THE PIN, CHECKED BEFORE ANYTHING ELSE. A deck we have paired with is
119
+ // this key and no other; a key that does not match is not a peer whose
120
+ // details changed, it is a different machine at the same address.
121
+ if (expectPub && expectPub !== them.pub) {
122
+ return fail(new Error("a different deck is answering at that address"));
123
+ }
124
+ theirChallenge = msg.challenge;
125
+ theirFp = them.fp;
126
+ theirPub = them.pub;
127
+ // Mixed when both challenges say so, as at the listener, and then the
128
+ // listener's key is required: a deck of this version that says it
129
+ // mixes and sends no key is not an older deck, it is a deck whose key
130
+ // was taken out on the way. Refused, and never read the old way.
131
+ const mixing = mixesEphemeral(myChallenge) && mixesEphemeral(theirChallenge);
132
+ const theirEphemeral = mixing ? readEphemeral(msg.epk) : null;
133
+ if (mixing && !theirEphemeral) return fail(new Error("bad challenge"));
134
+ transcript = mixing
135
+ ? handshakeTranscript(fp, theirFp, myChallenge, theirChallenge, myEpk, theirEphemeral)
136
+ : handshakeTranscript(fp, theirFp, myChallenge, theirChallenge);
137
+ try {
138
+ key = sessionKey(secret, theirPub, transcript,
139
+ mixing ? { role: "caller", priv: mine.priv, peer: theirEphemeral } : null);
140
+ } catch {
141
+ return fail(new Error("bad challenge"));
142
+ } finally {
143
+ // Used or not, the private half goes now.
144
+ mine = null;
145
+ }
146
+ sendFrame(sock, {
147
+ t: "auth",
148
+ proof: proof(key, {
149
+ challenge: myChallenge, peerChallenge: theirChallenge,
150
+ fromFp: fp, toFp: theirFp, direction: "hello",
151
+ }),
152
+ // Only when joining on an invite. Sent in the same frame as the
153
+ // session proof so a deck that holds a token is paired in one round
154
+ // trip rather than being queued behind somebody else's press.
155
+ ...(code ? { invite: inviteProof(code, transcript) } : {}),
156
+ });
157
+ };
158
+
159
+ /** Step 4: the listener's own proof — and the invite's, when this deck is
160
+ * joining on one — and then the connection, handed to whoever dialled. */
161
+ const ok = msg => {
162
+ // The same deck that gave us the challenge, or nothing: a reply naming a
163
+ // different fingerprint is a second party in the middle of this.
164
+ //
165
+ // AND A CHALLENGE FIRST. `theirFp` is null until one is accepted, so an
166
+ // `ok` sent as the very first frame with `fp: null` matched it — null is
167
+ // null — and walked into `proof` with no key, which throws. Before #1146
168
+ // that throw ended the deck, and this end dials whatever address a beacon
169
+ // announces; since #1146 the reader catches it as "bad frame". Neither is
170
+ // the answer: an `ok` before a challenge is out of order, and says so.
171
+ if (msg.t !== "ok" || !theirFp || !key || msg.fp !== theirFp) return fail(new Error("expected ok"));
172
+ const want = proof(key, {
173
+ challenge: theirChallenge, peerChallenge: myChallenge,
174
+ fromFp: theirFp, toFp: fp, direction: "reply",
175
+ });
176
+ if (!proofOk(want, msg.proof)) return fail(new Error("that deck could not prove its own key"));
177
+ // AND THAT IT IS THE DECK THE INVITE NAMED. The proof above is about a
178
+ // key the responder chose a moment ago; this one is about a code it had
179
+ // to have been given. `join` has no pin to pass, so `expectPub` above is
180
+ // null on this path and this is the only check that distinguishes the
181
+ // deck whose owner minted the token from whatever else is reachable at
182
+ // one of the ten addresses the token happens to carry.
183
+ //
184
+ // The caller giving up here is not the end of the attempt: `join` walks
185
+ // the rest of the list, so a deck answering at a stale or borrowed
186
+ // address costs one failed address instead of winning the whole token.
187
+ if (code && inviteProvesBack) {
188
+ const back = inviteProofBack(code, transcript);
189
+ if (!proofOk(back, msg.inviteProof)) {
190
+ return fail(new Error("that deck does not hold the invite"));
191
+ }
192
+ }
193
+ // Sealed from here when both challenges said so. The listener decided
194
+ // the same at the same moment from the same two strings, and the proof
195
+ // just checked is what says it saw the same two. See frameChannel.
196
+ const chan = sealsFrames(myChallenge) && sealsFrames(theirChallenge) ? frameChannel(key, "caller") : null;
197
+ settled = true;
198
+ clearTimeout(timer);
199
+ sock.removeAllListeners("close");
200
+ resolve({
201
+ sock, key, sealed: !!chan,
202
+ peerFp: theirFp, peerPub: theirPub, peerName: cleanName(msg.name, ""),
203
+ send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
204
+ /** What a frame from the other end says — or null on a sealed
205
+ * connection when it does not open, which ends the connection: every
206
+ * frame after it would fail too. On a connection to a deck from before
207
+ * #810, a plain frame passes through as it arrived. */
208
+ read: frame => (chan ? chan.unwrap(frame) : frame),
209
+ });
210
+ };
211
+
212
+ sock.on("data", frameReader(msg => {
213
+ if (settled) return;
214
+ // A deck that heard us and said no. Each reason is a different problem
215
+ // with a different fix, and until this frame existed they were all one
216
+ // silent close that read as a firewall. See REFUSALS.
217
+ if (msg.t === "no") {
218
+ // `Object.hasOwn`, and here more than anywhere: `msg.why` is a field in
219
+ // a frame written by the OTHER MACHINE, which is the case admin-failure
220
+ // states the rule for (#474). Every member of Object.prototype answers
221
+ // a plain bracket read with an inherited value that is neither nullish
222
+ // nor falsy, so `?? "the other deck refused this handshake"` never
223
+ // fired for one — `{...}["constructor"]` is the Object function itself,
224
+ // and `new Error(Object).message` is the string "function Object() {
225
+ // [native code] }". lan-engine files that as `lastRound.error` and the
226
+ // LAN panel prints it verbatim, so a peer chose what appeared in the
227
+ // user's interface. Asking whether the map has a ROW is the question
228
+ // this read was always trying to ask; no real reason moves.
229
+ return fail(new Error(Object.hasOwn(REFUSALS, msg.why) ? REFUSALS[msg.why]
230
+ : "the other deck refused this handshake"));
231
+ }
232
+ if (msg.t === "challenge") return challenge(msg);
233
+ return ok(msg);
234
+ // A refusal from the reader, with the thrown error as its cause when there
235
+ // was one: the message is the one it always was, and whatever logs the
236
+ // rejection can reach what actually threw.
237
+ }, (why, cause) => fail(cause ? new Error(why, { cause }) : new Error(why))));
238
+ });
239
+ }
@@ -0,0 +1,270 @@
1
+ // Which copy of a login wins when two decks both hold one, what a deck tells
2
+ // the decks it is paired with about the logins it holds — the lists a manifest
3
+ // carries, as they go out and as they are read coming in — and the words for
4
+ // why a login did not move. Moved out of lan-sync.mjs, which re-exports all of
5
+ // it: none of it reads the beacon, the handshake or the seals, and the identity
6
+ // of an account is the one rule modules far from this feature ask for. Like
7
+ // everything there it is pure — no sockets, no timers, no filesystem.
8
+ import { storedCopyAlive } from "./account-health.mjs";
9
+
10
+ // ── which copy wins ─────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * The identity of one account, which is NOT its slot number.
14
+ *
15
+ * claude-swap keys on `(email, organizationUuid)` — same email under two orgs
16
+ * is two accounts on purpose — and assigns slots as max+1 per store, so the
17
+ * account that is 4 here is 2 there. Anything keyed on the number would swap
18
+ * the wrong pair the first time two stores had grown in a different order.
19
+ */
20
+ export function accountKey(email, orgUuid) {
21
+ return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
22
+ }
23
+
24
+ /**
25
+ * What to do about one account, given what I have and what a peer has.
26
+ *
27
+ * TWO OUTCOMES, AND NEITHER OVERWRITES SOMETHING THAT WORKS.
28
+ *
29
+ * "add" I do not have this account at all.
30
+ * "heal" I have it and claude-swap has quarantined it — refresh token dead,
31
+ * verified by an invalid_grant from Anthropic rather than guessed
32
+ * from an expiry field — and the peer's copy is alive.
33
+ * null Anything else, which is most of the time.
34
+ *
35
+ * A third outcome was designed and dropped, and the reason is worth keeping
36
+ * because it looks like a feature being given up. It was "replace": take a
37
+ * peer's copy when it is strictly newer than mine, so the freshest copy wins
38
+ * everywhere. Measuring "newer" needs the OAuth payload's `expiresAt`, which
39
+ * means the deck opening a credential — something it does not do, claude-swap
40
+ * owns that — and on macOS that credential is in the Keychain, which a
41
+ * background process cannot reliably read at all.
42
+ *
43
+ * What settled it is not the obstacle. It is that a working credential replaced
44
+ * by a newer working credential changes nothing today. It would only matter if
45
+ * mine were about to die — and a login dies from not being used, so if I am not
46
+ * using it I do not care, and if I am using it the refresh keeps it alive. The
47
+ * whole value is in the account that is already dead.
48
+ *
49
+ * So this never returns an outcome that needs `cswap import --force`, which
50
+ * makes claude-swap's own rule the entire safety property: a plain import skips
51
+ * an account that is present and healthy, and replaces exactly one that is
52
+ * quarantined. A peer cannot overwrite a credential of mine that works, because
53
+ * nothing here ever asks for that.
54
+ */
55
+ export function syncAction(mine, theirs) {
56
+ if (!theirs || !theirs.alive || theirs.shareable === false) return null;
57
+ if (!mine) return "add";
58
+ return mine.alive ? null : "heal";
59
+ }
60
+
61
+ /**
62
+ * Everything to do this round, over one peer's manifest.
63
+ *
64
+ * Sorted by account key rather than left in manifest order, so two decks
65
+ * reconciling the same pair of stores do the same work in the same sequence —
66
+ * which is what makes a failure halfway through repeatable rather than a
67
+ * different half each time.
68
+ */
69
+ export function plan(local, remote) {
70
+ // Through onePerKey, not array order: a Map built straight from the rows
71
+ // keeps the LAST one for a key, so an expired second slot hid a live first
72
+ // one and every round "healed" a login that works here.
73
+ const mine = new Map(onePerKey(local).map(a => [a.key, a]));
74
+ const out = [];
75
+ for (const theirs of remote) {
76
+ const action = syncAction(mine.get(theirs.key), theirs);
77
+ if (action) out.push({ key: theirs.key, email: theirs.email, action });
78
+ }
79
+ return out.sort((a, b) => a.key.localeCompare(b.key));
80
+ }
81
+
82
+ /**
83
+ * One row per identity, and THE rule for which one when claude-swap holds two
84
+ * slots for the same login: a live copy beats an expired one, then one this
85
+ * process can read beats one it cannot, then the earlier slot.
86
+ *
87
+ * ONE RULE, FOUR PLACES. The manifest this deck sends, the peer manifest it
88
+ * reads, the export it answers a `want` with, and plan's view of this deck's
89
+ * own copy all pick through this, so the slot a peer was told about is the
90
+ * slot it is then handed, and the copy this deck advertises as working is the
91
+ * one it plans from. Each used to take array order on its own and agreed only
92
+ * by coincidence — and two slots for one identity became two imports of it in
93
+ * a single round.
94
+ */
95
+ export function onePerKey(rows) {
96
+ const rank = a => (a.alive ? 2 : 0) + (a.readable === false ? 0 : 1);
97
+ const best = new Map();
98
+ for (const a of rows) {
99
+ const had = best.get(a.key);
100
+ if (!had || rank(a) > rank(had)) best.set(a.key, a);
101
+ }
102
+ return [...best.values()];
103
+ }
104
+
105
+ /** This deck's own slot for one identity, by onePerKey's rule, or null. */
106
+ export function slotFor(accounts, key) {
107
+ return onePerKey(accounts.filter(a => a.key === key))[0] ?? null;
108
+ }
109
+
110
+ /**
111
+ * What this deck publishes about its own accounts — to the group, and only to
112
+ * the group.
113
+ *
114
+ * `alive` says whether the stored copy is still valid. `shareable: false` is
115
+ * the separate, temporary condition where this process cannot read that valid
116
+ * copy (for example a locked macOS Keychain). Keeping those facts separate is
117
+ * what stops another deck treating a healthy-but-inaccessible copy as expired
118
+ * and repeatedly trying to heal it.
119
+ *
120
+ * Emails are in it, and the manifest is sealed on its way. This said "in the
121
+ * clear inside the encrypted channel" until #810, and there was no such
122
+ * channel: the handshake derived a key and sealed a credential and a version
123
+ * card with it, and nothing else — so every email in this list crossed the
124
+ * network readable by anybody on it. Between two decks that both seal, every
125
+ * frame after the handshake now is (see frameChannel). Toward a deck from
126
+ * before that it still travels plain, because that deck cannot open anything
127
+ * else, until it updates.
128
+ *
129
+ * Plain emails INSIDE the seal are a deliberate line: a manifest only ever
130
+ * reaches a deck that proved the key somebody here accepted, and the panel has
131
+ * to name the account it is offering to heal. Hashing the email would buy
132
+ * nothing against that reader and would cost the one thing the row needs to say.
133
+ *
134
+ * `shared` is the user's list. An account absent from it is absent from the
135
+ * manifest entirely — not listed as withheld, which would tell the group that
136
+ * an account exists and is being kept back, and that is itself the fact being
137
+ * kept back.
138
+ */
139
+ export function manifestFor(accounts, shared) {
140
+ const want = new Set(shared);
141
+ return onePerKey(accounts.filter(a => want.has(a.key)))
142
+ .map(a => ({
143
+ key: a.key,
144
+ email: a.email,
145
+ alive: !!a.alive,
146
+ ...(a.readable === false ? { shareable: false } : {}),
147
+ }))
148
+ .sort((a, b) => a.key.localeCompare(b.key));
149
+ }
150
+
151
+ /**
152
+ * Which account this deck is on, as a frame field for the decks it is paired
153
+ * with — or nothing.
154
+ *
155
+ * ONLY ONE IT SHARES IS EVER NAMED. The account a deck works on is named only
156
+ * when it is also in the list the owner ticked, so the promise the share list
157
+ * makes — an unticked account is never told to anybody — holds for this too.
158
+ * On one it does not share, it says `other` and nothing more: a paired deck
159
+ * reads "on another account", which says this machine is working without
160
+ * saying on what.
161
+ *
162
+ * `hidden` is the owner's switch turned off, and that IS said, on purpose: a
163
+ * paired deck then reads "current account hidden" rather than nothing, so the
164
+ * person over there knows this machine is working and chose not to say where.
165
+ * A deck with no account at all says nothing.
166
+ */
167
+ export function currentFor(accounts, shared, shareActive) {
168
+ if (shareActive === false) return { current: { hidden: true } };
169
+ const on = accounts.find(a => a.active);
170
+ if (!on) return {};
171
+ return new Set(shared).has(on.key) ? { current: { key: on.key } } : { current: { other: true } };
172
+ }
173
+
174
+ /** One peer-supplied string as the panel may draw it: no control or format
175
+ * characters, whitespace collapsed, bounded. The rule cleanName applies to a
176
+ * deck's name and lan-about's `field` to a card, applied to the two strings
177
+ * that sit next to the fingerprint in the import dialog. */
178
+ function flatten(v, max) {
179
+ if (typeof v !== "string") return "";
180
+ const flat = v.replace(/\p{Cc}/gu, " ").replace(/\p{Cf}/gu, "").replace(/\s+/g, " ").trim();
181
+ return [...flat].slice(0, max).join("");
182
+ }
183
+
184
+ /**
185
+ * The accounts a peer's manifest listed, as the panel may keep them.
186
+ *
187
+ * It arrived from another machine, so it is read rather than trusted: strings
188
+ * where strings belong, a boolean for the verdict, and no more rows than a
189
+ * manifest may carry. What is kept is only what the deck's dialog draws.
190
+ */
191
+ export function offered(list) {
192
+ return onePerKey((Array.isArray(list) ? list : [])
193
+ .filter(a => a && typeof a.key === "string" && typeof a.email === "string")
194
+ // Character-filtered, not merely cut. These two are drawn beside the
195
+ // fingerprint at the moment the operator picks which of a peer's logins to
196
+ // import (LanPeerModal.tsx:497), and a bare slice let a format character
197
+ // through — the same class cleanName strips from the name one frame over.
198
+ .map(a => ({
199
+ key: flatten(a.key, 320),
200
+ email: flatten(a.email, 254),
201
+ alive: storedCopyAlive(a.alive, a.collector),
202
+ // Additive wire field: an older peer omitted it, which means shareable.
203
+ ...(a.shareable === false ? { shareable: false } : {}),
204
+ }))
205
+ .filter(a => a.key && a.email))
206
+ // Fifty IDENTITIES, so the cap is spent after onePerKey rather than before
207
+ // it: a peer with duplicate slots could otherwise push a live copy past
208
+ // row fifty and out of the list while offering far fewer than fifty
209
+ // logins. The raw array is already bounded by MAX_FRAME_BYTES.
210
+ .slice(0, 50);
211
+ }
212
+
213
+ /**
214
+ * Which account a peer said it is on, as the panel may keep it: the key of one
215
+ * of the accounts it listed in the same frame, that its owner is hiding it, or
216
+ * that it is on one it does not share — and nothing for anything else. A key
217
+ * that is not in its own list is dropped rather than drawn: a deck only ever
218
+ * names an account it shares, and one that names another is saying something
219
+ * this deck will not show.
220
+ */
221
+ export function heardCurrent(raw, list) {
222
+ if (!raw || typeof raw !== "object") return null;
223
+ if (raw.hidden === true) return { hidden: true };
224
+ if (raw.other === true) return { other: true };
225
+ if (typeof raw.key !== "string") return null;
226
+ return list.some(a => a.key === raw.key) ? { key: raw.key } : null;
227
+ }
228
+
229
+ // ── why a login did not move ────────────────────────────────────────────────
230
+
231
+ /**
232
+ * The reason a sending deck gives for a login it holds and cannot read: on a
233
+ * Mac, claude-swap could not open the Keychain from the session this deck runs
234
+ * in. A fixed code, never the CLI's words — export's stdout IS the credential,
235
+ * and nothing it printed is any business of the deck asking.
236
+ */
237
+ export const SENDER_UNREADABLE = "keychain_unavailable";
238
+
239
+ /** The reasons `serve` gives for a login it will not send. Its other answer to
240
+ * a `want`, "not paired", is not in the set, so a deck that gets it records
241
+ * "refused". */
242
+ const WIRE_REFUSALS = new Set(["proof", "not shared", "not mine to give", "export failed", SENDER_UNREADABLE, "error"]);
243
+
244
+ /**
245
+ * A peer's refusal, as this deck records it. The panel prints these, so a peer
246
+ * must not be able to put words in its mouth — least of all one of the HERE
247
+ * codes, which are sentences about this machine. Anything outside the closed
248
+ * set is "refused", which is all it ever proved.
249
+ */
250
+ export function peerWhy(why) {
251
+ return typeof why === "string" && WIRE_REFUSALS.has(why) ? why : "refused";
252
+ }
253
+
254
+ /**
255
+ * What this deck found wrong with a login it received. Produced by the local
256
+ * adapters only and never read off a frame — see peerWhy — so a code here is
257
+ * always about THIS machine, and on an arrived row it is a warning rather than
258
+ * a failure: the credential landed and something about this machine stops it
259
+ * being used.
260
+ */
261
+ export const HERE = Object.freeze({
262
+ // claude-swap cannot open this Mac's Keychain from the deck's session.
263
+ unreadable: "unreadable_here",
264
+ // It landed, and claude-swap still holds no login for it.
265
+ noLogin: "no_credentials_here",
266
+ // It landed, and the login it carried was rejected.
267
+ expired: "relogin_required_here",
268
+ // It landed, and claude-swap could not be asked whether it is readable.
269
+ unverified: "unverified_here",
270
+ });