ccdeck 3.38.0 → 3.38.2
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 +3 -3
- package/dist/web/assets/{BrowserWatchModal-BPObeZn1.js → BrowserWatchModal-CwkyKvpa.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-Drv13uht.js → UsageHistoryModal-D8YK0c4C.js} +1 -1
- package/dist/web/assets/index-5Bss6_uX.js +309 -0
- package/dist/web/assets/index-6_mJZ_aP.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/deck-prefs.mjs +5 -2
- package/src/server/event-ring.mjs +10 -1
- package/src/server/event-routes.mjs +45 -1
- package/src/server/evicted-enrichment.mjs +213 -0
- package/src/server/lan-engine.mjs +40 -34
- package/src/server/lan-reach.mjs +21 -0
- package/src/server/lan-routes.mjs +4 -1
- package/src/server/lan-sync.mjs +6 -5
- package/dist/web/assets/index-0cUs2Jed.css +0 -1
- package/dist/web/assets/index-C_h8mR-c.js +0 -307
package/dist/web/index.html
CHANGED
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
65
65
|
})();
|
|
66
66
|
</script>
|
|
67
|
-
<script type="module" crossorigin src="/assets/index-
|
|
68
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
67
|
+
<script type="module" crossorigin src="/assets/index-5Bss6_uX.js"></script>
|
|
68
|
+
<link rel="stylesheet" crossorigin href="/assets/index-6_mJZ_aP.css">
|
|
69
69
|
</head>
|
|
70
70
|
<body>
|
|
71
71
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.38.
|
|
3
|
+
"version": "3.38.2",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -133,8 +133,11 @@ export const DEFAULTS = Object.freeze({
|
|
|
133
133
|
// WHAT MAKES IT SURVIVABLE IS THE GATE THAT NEVER CHANGED: `shared` is
|
|
134
134
|
// empty, so a deck that pairs is offered NOTHING until somebody ticks a
|
|
135
135
|
// login here. Pairing is a name in a list; a login is the thing worth
|
|
136
|
-
// having, and it still takes a deliberate tick on this machine.
|
|
137
|
-
//
|
|
136
|
+
// having, and it still takes a deliberate tick on this machine. The other
|
|
137
|
+
// direction is not gated by a tick here, since 3.38.1: a deck the switch
|
|
138
|
+
// pairs adds and repairs the logins a person ticked on it, as any paired
|
|
139
|
+
// deck does (see roundWith), and never replaces one that works. So a deck
|
|
140
|
+
// on a network it does not own turns this off in the dialog, in one press.
|
|
138
141
|
autoAsk: true,
|
|
139
142
|
autoAccept: true,
|
|
140
143
|
pairingMode: "automatic",
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// readers get a copy or an answer rather than the array. The bodies are
|
|
10
10
|
// unchanged.
|
|
11
11
|
import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, payloadChars } from "./ring-bounds.mjs";
|
|
12
|
+
// The newest enrichment of each kind per session that eviction takes off the
|
|
13
|
+
// head, for a page that connects after it went — see evicted-enrichment.mjs.
|
|
14
|
+
import { clearEvicted, noteEvicted } from "./evicted-enrichment.mjs";
|
|
12
15
|
|
|
13
16
|
// ─── The event ring buffer ─────────────────────────────────────────────────
|
|
14
17
|
// Its two bounds, MAX_BUFFER and MAX_BUFFER_CHARS, and the charge an event is
|
|
@@ -62,6 +65,8 @@ export function clearEventBuffer() {
|
|
|
62
65
|
events.length = 0;
|
|
63
66
|
bufferedChars = 0;
|
|
64
67
|
bufferedHookEvents = 0;
|
|
68
|
+
// And what eviction kept back from it: a cleared deck has nothing to resend.
|
|
69
|
+
clearEvicted();
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -206,9 +211,13 @@ export function admitEvent(raw, source, receivedAt) {
|
|
|
206
211
|
drop++;
|
|
207
212
|
}
|
|
208
213
|
if (drop > 0) {
|
|
209
|
-
events.splice(0, drop);
|
|
214
|
+
const evicted = events.splice(0, drop);
|
|
210
215
|
bufferedChars -= freed;
|
|
211
216
|
bufferedHookEvents -= freedHookEvents;
|
|
217
|
+
// What leaves is gone for every page that had not been sent it — except
|
|
218
|
+
// the enrichment, whose newest value per session is kept back for those
|
|
219
|
+
// pages, because an idle session never sends it again.
|
|
220
|
+
for (const e of evicted) if (e[ENRICHMENT]) noteEvicted(e, e[CHARS]);
|
|
212
221
|
}
|
|
213
222
|
return evt;
|
|
214
223
|
}
|
|
@@ -22,6 +22,9 @@ import { noteLogWriter } from "./event-log.mjs";
|
|
|
22
22
|
import { deckProviders } from "./deck-scope.mjs";
|
|
23
23
|
// The names only the server may send — see ring-bounds.mjs.
|
|
24
24
|
import { isReservedEventName } from "./ring-bounds.mjs";
|
|
25
|
+
// The enrichment the ring evicted, handed to a page that connects behind its
|
|
26
|
+
// head — see withEvictedEnrichment.
|
|
27
|
+
import { evictedEnrichment } from "./evicted-enrichment.mjs";
|
|
25
28
|
// The SSE subscribers and the backpressure every frame to them is written
|
|
26
29
|
// under — see sse-clients.mjs.
|
|
27
30
|
import { dropSse, sseClients, trayClients, writeResume, writeSse } from "./sse-clients.mjs";
|
|
@@ -253,6 +256,11 @@ export function handleSse(req, res) {
|
|
|
253
256
|
// guard is `env.seq <= state.lastSeq`, so the gap costs it a step forward and
|
|
254
257
|
// nothing else. What the byte budget changed is how often and how far the
|
|
255
258
|
// head moves, not what happens to a client that lands behind it.
|
|
259
|
+
//
|
|
260
|
+
// One thing in the gap is not history: the newest model, name, usage and the
|
|
261
|
+
// rest of each session's last-value-wins enrichment, which an idle session
|
|
262
|
+
// never sends again. That is put back in front of the replay — see
|
|
263
|
+
// withEvictedEnrichment.
|
|
256
264
|
const asked = Number(req.headers["last-event-id"] ?? 0);
|
|
257
265
|
const lastId = Number.isFinite(asked) ? asked : 0;
|
|
258
266
|
|
|
@@ -311,7 +319,8 @@ async function resumeSse(req, res, lastId, { tray = false } = {}) {
|
|
|
311
319
|
// means a slow resumer pins one ring's worth of envelopes for as long as its
|
|
312
320
|
// pass lasts, which the budget bounds too: that pin used to be unbounded for
|
|
313
321
|
// the same reason the ring was.
|
|
314
|
-
|
|
322
|
+
// Behind the ring's head, the enrichment that fell off it goes first.
|
|
323
|
+
const batch = withEvictedEnrichment(ringSnapshot(), sentThrough);
|
|
315
324
|
for (const e of batch) {
|
|
316
325
|
if (e.seq <= sentThrough) continue;
|
|
317
326
|
if (closed || res.destroyed) return;
|
|
@@ -372,3 +381,38 @@ async function resumeSse(req, res, lastId, { tray = false } = {}) {
|
|
|
372
381
|
ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
|
|
373
382
|
if (!await flushed) dropSse(res);
|
|
374
383
|
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A replay pass's snapshot, with what this page lost to eviction put in front
|
|
387
|
+
* of it.
|
|
388
|
+
*
|
|
389
|
+
* When the ring's oldest event is newer than the next one this page needs —
|
|
390
|
+
* a fresh tab on a busy deck, a reload, a laptop waking — everything between
|
|
391
|
+
* was evicted, and a session's model, name, usage, context, activity line and
|
|
392
|
+
* job went with it if they were in there: they are sent when they change, so
|
|
393
|
+
* an idle session's card would be drawn without them for as long as it stayed
|
|
394
|
+
* idle. The newest value of each, for every session this snapshot holds an
|
|
395
|
+
* event of (the cards the page will draw), is put in front under its own seq
|
|
396
|
+
* (see evicted-enrichment.mjs), so the loop below sends it as one more replay
|
|
397
|
+
* frame, in order, before the ring. Every pass asks, against what this page
|
|
398
|
+
* has been sent so far: a slow replay the head overtook between passes is
|
|
399
|
+
* handed what it lost then too, and one that is caught up is handed nothing.
|
|
400
|
+
*
|
|
401
|
+
* The page holds a value that arrives before its card and gives it to the
|
|
402
|
+
* card when the card is made (parked-enrichment.ts).
|
|
403
|
+
*/
|
|
404
|
+
function withEvictedEnrichment(batch, sentThrough) {
|
|
405
|
+
if (batch.length === 0 || batch[0].seq <= sentThrough + 1) return batch;
|
|
406
|
+
try {
|
|
407
|
+
const sessions = new Set();
|
|
408
|
+
for (const e of batch) {
|
|
409
|
+
const sid = e.payload?.session_id;
|
|
410
|
+
if (typeof sid === "string" && sid) sessions.add(sid);
|
|
411
|
+
}
|
|
412
|
+
const lost = evictedEnrichment({ after: sentThrough, before: batch[0].seq, sessions });
|
|
413
|
+
return lost.length > 0 ? lost.concat(batch) : batch;
|
|
414
|
+
} catch {
|
|
415
|
+
// A page is never refused its replay for this.
|
|
416
|
+
return batch;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The newest value of each last-value-wins enrichment the ring has evicted,
|
|
2
|
+
// per session — what a page that connects behind the ring's head is handed
|
|
3
|
+
// ahead of the replay, so it is not left without them.
|
|
4
|
+
//
|
|
5
|
+
// WHY THESE GO MISSING. A session's model, name, usage, context, activity line
|
|
6
|
+
// and background job (LAST_VALUE_WINS in ring-bounds.mjs) are each sent when
|
|
7
|
+
// they change, and the scanners gate most of them on that: an idle session's
|
|
8
|
+
// ModelObserved and SessionNamed went out once, near its start. The ring keeps
|
|
9
|
+
// the newest MAX_BUFFER hook events, so on a busy deck those events fall off
|
|
10
|
+
// its head while the session's later hook events are still held. A reload or a
|
|
11
|
+
// second tab is replayed what is left: the card is drawn, from its surviving
|
|
12
|
+
// hook events, with no model chip, no name and a stale token count, and nothing
|
|
13
|
+
// sends them again until the session does something.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IS KEPT. Every enrichment envelope the ring evicts is folded here into
|
|
16
|
+
// the newest value of its kind for its session (foldEnrichment), and keeps the
|
|
17
|
+
// seq, time and source of the last one folded. A page whose Last-Event-ID is
|
|
18
|
+
// older than what was evicted is sent these BEFORE the ring, under their own
|
|
19
|
+
// seq — see withEvictedEnrichment in event-routes.mjs — so the page applies
|
|
20
|
+
// them exactly where the ring would have, and every newer event still in the
|
|
21
|
+
// ring lands on top of them as it would have. Nothing is pushed into the ring,
|
|
22
|
+
// nothing is broadcast to the pages already connected and nothing is logged;
|
|
23
|
+
// and because each keeps its own `receivedAt`, none of them reads to the page
|
|
24
|
+
// as the session being heard from just now.
|
|
25
|
+
//
|
|
26
|
+
// Fed by admitEvent (event-ring.mjs) as it evicts, and emptied with the ring by
|
|
27
|
+
// clearEventBuffer, so a Clear forgets these as it forgets the ring.
|
|
28
|
+
import { payloadChars } from "./ring-bounds.mjs";
|
|
29
|
+
|
|
30
|
+
/** The kinds kept: the session's own last-value-wins enrichment, each read by
|
|
31
|
+
* its applier in transcript-events.ts. Named here rather than read off
|
|
32
|
+
* LAST_VALUE_WINS, because the fold below is written for exactly these and a
|
|
33
|
+
* kind added there is not folded right by rules it was never written for. */
|
|
34
|
+
const KINDS = new Set([
|
|
35
|
+
"ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed",
|
|
36
|
+
"ActivityObserved", "JobObserved",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** The most sessions kept, least recently folded dropped first — the deck's
|
|
40
|
+
* own working number for the sessions it tracks (MAX_TRACKED_SESSIONS in
|
|
41
|
+
* session-tracking.mjs), well above the 200 cards a page draws. */
|
|
42
|
+
export const MAX_EVICTED_SESSIONS = 256;
|
|
43
|
+
|
|
44
|
+
/** What everything kept may weigh, in the characters payloadChars charges,
|
|
45
|
+
* and what one value may. Real values are a few kilobytes — a usage total
|
|
46
|
+
* split by model, a context breakdown with its memory files, a session's
|
|
47
|
+
* subagent models — so the cap on one value is only ever met by a payload no
|
|
48
|
+
* scanner wrote; `POST /api/event` takes enrichment names from anyone, and
|
|
49
|
+
* this must not become a second ring with no budget. */
|
|
50
|
+
export const MAX_EVICTED_CHARS = 8 * 1024 * 1024;
|
|
51
|
+
export const MAX_EVICTED_VALUE_CHARS = 256 * 1024;
|
|
52
|
+
|
|
53
|
+
// sid -> Map<hook_event_name, { seq, epoch, receivedAt, source, payload, chars }>,
|
|
54
|
+
// both in least-recently-folded order.
|
|
55
|
+
const kept = new Map();
|
|
56
|
+
let keptChars = 0;
|
|
57
|
+
|
|
58
|
+
const isObject = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
59
|
+
const nonEmpty = v => typeof v === "string" && v.trim() !== "";
|
|
60
|
+
|
|
61
|
+
/** The facts the reducer stamps off whatever payload carries them
|
|
62
|
+
* (stampSessionFacts), each with the test it is stamped under: anything else
|
|
63
|
+
* says nothing, so it never replaces a value that does. */
|
|
64
|
+
const STAMPED = [
|
|
65
|
+
["model_context_window", v => typeof v === "number" && v > 0],
|
|
66
|
+
["approval_policy", v => typeof v === "string" && v !== ""],
|
|
67
|
+
["context_tokens", v => typeof v === "number" && v >= 0],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const JOB_STATES = new Set(["working", "blocked", "done", "failed", "stopped"]);
|
|
71
|
+
const validActivity = a => isObject(a) && typeof a.text === "string" && a.text.trim() !== "" && Number.isFinite(a.at);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Two enrichment payloads of one kind, for one session, as the one payload
|
|
75
|
+
* that leaves a card where the two in a row would have.
|
|
76
|
+
*
|
|
77
|
+
* The rules are the reducer's own (transcript-events.ts), and the page folds
|
|
78
|
+
* what waits for a card by the same ones (parked-enrichment.ts):
|
|
79
|
+
* - a field the newer payload leaves out, or leaves undefined, says nothing;
|
|
80
|
+
* - the stamped facts, a ModelObserved's model and a SessionNamed's two
|
|
81
|
+
* names count only when they hold a value;
|
|
82
|
+
* - a context breakdown merges key by key, and subagent models by subagent;
|
|
83
|
+
* - a SessionNamed title that repeats the name is dropped, as the card drops it;
|
|
84
|
+
* - an activity line never goes backwards in time;
|
|
85
|
+
* - a job of `null` is the job gone, and one the reducer would refuse says nothing;
|
|
86
|
+
* - a usage total replaces the last one whole, its split by model included,
|
|
87
|
+
* and a UsageObserved without one says nothing about either.
|
|
88
|
+
*/
|
|
89
|
+
export function foldEnrichment(prev, next) {
|
|
90
|
+
if (!isObject(prev)) return next;
|
|
91
|
+
const out = { ...prev };
|
|
92
|
+
for (const [k, v] of Object.entries(next)) if (v !== undefined) out[k] = v;
|
|
93
|
+
const keep = k => { if (k in prev) out[k] = prev[k]; else delete out[k]; };
|
|
94
|
+
for (const [k, ok] of STAMPED) if (!ok(next[k])) keep(k);
|
|
95
|
+
switch (next.hook_event_name) {
|
|
96
|
+
case "ModelObserved":
|
|
97
|
+
if (!(typeof next.model === "string" && next.model !== "")) keep("model");
|
|
98
|
+
if (isObject(prev.subagentModels) || isObject(next.subagentModels)) {
|
|
99
|
+
out.subagentModels = {
|
|
100
|
+
...(isObject(prev.subagentModels) ? prev.subagentModels : {}),
|
|
101
|
+
...(isObject(next.subagentModels) ? next.subagentModels : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
case "ContextObserved": {
|
|
106
|
+
if (!isObject(next.context)) { keep("context"); break; }
|
|
107
|
+
const ctx = isObject(prev.context) ? { ...prev.context } : {};
|
|
108
|
+
for (const [k, v] of Object.entries(next.context)) if (typeof v === "number" || Array.isArray(v)) ctx[k] = v;
|
|
109
|
+
out.context = ctx;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "SessionNamed": {
|
|
113
|
+
const name = nonEmpty(next.sessionName) ? next.sessionName : (prev.sessionName ?? null);
|
|
114
|
+
let title = nonEmpty(next.sessionTitle) ? next.sessionTitle : (prev.sessionTitle ?? null);
|
|
115
|
+
if (nonEmpty(name) && nonEmpty(title) && title.trim().toLowerCase() === name.trim().toLowerCase()) title = null;
|
|
116
|
+
out.sessionName = name;
|
|
117
|
+
out.sessionTitle = title;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "ActivityObserved":
|
|
121
|
+
if (!validActivity(next.activity) || (validActivity(prev.activity) && next.activity.at < prev.activity.at)) keep("activity");
|
|
122
|
+
break;
|
|
123
|
+
case "JobObserved":
|
|
124
|
+
if (!(next.job === null || (isObject(next.job) && JOB_STATES.has(next.job.state)))) keep("job");
|
|
125
|
+
break;
|
|
126
|
+
case "UsageObserved":
|
|
127
|
+
if (!isObject(next.usage)) { keep("usage"); keep("usageByModel"); }
|
|
128
|
+
else if (next.usageByModel === undefined) delete out.usageByModel;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function drop(sid, name) {
|
|
135
|
+
const kinds = kept.get(sid);
|
|
136
|
+
const entry = kinds?.get(name);
|
|
137
|
+
if (!entry) return;
|
|
138
|
+
keptChars -= entry.chars;
|
|
139
|
+
kinds.delete(name);
|
|
140
|
+
if (kinds.size === 0) kept.delete(sid);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function dropSession(sid) {
|
|
144
|
+
for (const name of [...(kept.get(sid)?.keys() ?? [])]) drop(sid, name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One envelope the ring has just evicted. Anything that is not one of KINDS,
|
|
149
|
+
* or names no session, is not kept. `charged` is what the ring charged the
|
|
150
|
+
* envelope, so a payload no scanner could have written is let go without
|
|
151
|
+
* being walked a second time — and takes the older value of its kind with it,
|
|
152
|
+
* since a page told the older one would be told something the deck has since
|
|
153
|
+
* heard replaced.
|
|
154
|
+
*/
|
|
155
|
+
export function noteEvicted(evt, charged = 0) {
|
|
156
|
+
const p = evt?.payload;
|
|
157
|
+
if (!isObject(p) || !KINDS.has(p.hook_event_name)) return;
|
|
158
|
+
const sid = p.session_id;
|
|
159
|
+
if (typeof sid !== "string" || sid === "") return;
|
|
160
|
+
const name = p.hook_event_name;
|
|
161
|
+
if (charged > MAX_EVICTED_VALUE_CHARS) { drop(sid, name); return; }
|
|
162
|
+
const prev = kept.get(sid)?.get(name);
|
|
163
|
+
const payload = foldEnrichment(prev?.payload, p);
|
|
164
|
+
const chars = payloadChars(payload);
|
|
165
|
+
drop(sid, name);
|
|
166
|
+
if (chars > MAX_EVICTED_VALUE_CHARS) return;
|
|
167
|
+
// Re-inserted, so both maps' own order is the order things were last folded
|
|
168
|
+
// in and the caps below drop the session heard from longest ago.
|
|
169
|
+
const kinds = kept.get(sid) ?? new Map();
|
|
170
|
+
kept.delete(sid);
|
|
171
|
+
kept.set(sid, kinds);
|
|
172
|
+
kinds.set(name, { seq: evt.seq, epoch: evt.epoch, receivedAt: evt.receivedAt, source: evt.source, payload, chars });
|
|
173
|
+
keptChars += chars;
|
|
174
|
+
while (kept.size > MAX_EVICTED_SESSIONS || keptChars > MAX_EVICTED_CHARS) {
|
|
175
|
+
const oldest = kept.keys().next().value;
|
|
176
|
+
if (oldest === undefined) break;
|
|
177
|
+
dropSession(oldest);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Forget everything kept — the ring was cleared. */
|
|
182
|
+
export function clearEvicted() {
|
|
183
|
+
kept.clear();
|
|
184
|
+
keptChars = 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What a page is missing, as envelopes, oldest first: for each of `sessions`,
|
|
189
|
+
* the newest value of each kind whose last fold is newer than `after` (what
|
|
190
|
+
* the page was last sent) and older than `before` (the oldest seq the page is
|
|
191
|
+
* about to be replayed). A page that saw every one of them is sent none.
|
|
192
|
+
*
|
|
193
|
+
* @param {{ after: number, before: number, sessions: Set<string> }} range
|
|
194
|
+
*/
|
|
195
|
+
export function evictedEnrichment({ after, before, sessions }) {
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const sid of sessions) {
|
|
198
|
+
const kinds = kept.get(sid);
|
|
199
|
+
if (!kinds) continue;
|
|
200
|
+
for (const e of kinds.values()) {
|
|
201
|
+
if (e.seq <= after || e.seq >= before) continue;
|
|
202
|
+
out.push({ seq: e.seq, epoch: e.epoch, receivedAt: e.receivedAt, source: e.source, payload: e.payload });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out.sort((a, b) => a.seq - b.seq);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What is kept right now, for the suite: sessions, values and their weight. */
|
|
209
|
+
export function evictedStats() {
|
|
210
|
+
let values = 0;
|
|
211
|
+
for (const kinds of kept.values()) values += kinds.size;
|
|
212
|
+
return { sessions: kept.size, values, chars: keptChars };
|
|
213
|
+
}
|
|
@@ -397,7 +397,8 @@ export function createEngine({
|
|
|
397
397
|
* is new.
|
|
398
398
|
*
|
|
399
399
|
* Or one the accept switch chose for them, which `auto` says and the pin
|
|
400
|
-
* keeps — see addTrusted, and
|
|
400
|
+
* keeps — see addTrusted, and sharedWith in lan-sync.mjs for what such a
|
|
401
|
+
* deck is not handed.
|
|
401
402
|
*/
|
|
402
403
|
const pin = ({ fp, pub, name }, { auto = false } = {}) => {
|
|
403
404
|
const { list, added } = addTrusted(cfg.trusted, { fp, pub, name, at: now(), ...(auto ? { auto: true } : {}) });
|
|
@@ -727,11 +728,11 @@ export function createEngine({
|
|
|
727
728
|
if (!stillPaired()) throw new Error("peer no longer paired");
|
|
728
729
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
729
730
|
const list = keepManifest(conn.key, theirs, conn.peerFp);
|
|
730
|
-
// FROM
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
731
|
+
// FROM ANY PAIRED DECK, NEITHER AN ADD NOR A HEAL NEEDS MY TICK. Adding
|
|
732
|
+
// is the case the owner asked for by name: an account that appears among
|
|
733
|
+
// the decks I paired with appears on all of them, which is the whole of
|
|
734
|
+
// "I do not want to paste blobs any more". What can reach this is what a
|
|
735
|
+
// paired deck chose to offer.
|
|
735
736
|
//
|
|
736
737
|
// A heal used to need the tick, on the reasoning that it replaces a slot
|
|
737
738
|
// I already have. It replaces only one claude-swap has quarantined, with
|
|
@@ -746,13 +747,28 @@ export function createEngine({
|
|
|
746
747
|
// so this is the ordinary case between one person's machines, not an
|
|
747
748
|
// edge.
|
|
748
749
|
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
750
|
+
// A DECK THE SWITCH PAIRED INCLUDED, which is what 3.33.0 got wrong. It
|
|
751
|
+
// held such a deck to the tick — what it offered came in only for an
|
|
752
|
+
// account ticked here — on the reasoning that nobody here chose it. On
|
|
753
|
+
// the shipped defaults that was a deadlock rather than a gate. The
|
|
754
|
+
// accept switch ships on, so two of one person's machines pair by it;
|
|
755
|
+
// the setup dialog lists only this deck's own accounts, so a login this
|
|
756
|
+
// deck lacks cannot be ticked here; so an add from such a deck could
|
|
757
|
+
// never pass. A Mac ticked eight working logins, the deck it had paired
|
|
758
|
+
// with by the switch received none of them, ever, and its round line
|
|
759
|
+
// read "all logins fine". The owner chose fully automatic (2026-10-06):
|
|
760
|
+
// whoever made the pairing, a round takes everything plan() returns.
|
|
761
|
+
//
|
|
762
|
+
// What stands between a deck nobody here chose and this store does not
|
|
763
|
+
// rest on the tick, and none of it moved. plan() never replaces a login
|
|
764
|
+
// that works here (syncAction never asks for a forced import). A giving
|
|
765
|
+
// deck hands a deck its switch paired only what a PERSON ticked on it,
|
|
766
|
+
// not what an arrival ticked onward (sharedWith in lan-sync.mjs). And
|
|
767
|
+
// nothing that arrives from one is ticked onward here (ticksOnArrival).
|
|
768
|
+
// What it costs is the switch's own: on a network the owner does not
|
|
769
|
+
// control, a deck the switch pairs can add the logins ticked on it — so
|
|
770
|
+
// the dialog and the README say to turn the switch off there.
|
|
771
|
+
//
|
|
756
772
|
// OVER `list`, NOT THE RAW ARRAY. `offered`, which keepManifest runs the
|
|
757
773
|
// list through above (see lan-manifest.mjs), slices to 50 and type-
|
|
758
774
|
// filters `key` and `email`; this line read `theirs.accounts` and got
|
|
@@ -763,15 +779,7 @@ export function createEngine({
|
|
|
763
779
|
// the store lock, while the panel drew 50 and every other paired deck
|
|
764
780
|
// waited behind it. `step.key` also reached transferChallenge and
|
|
765
781
|
// importAccount untyped, which `offered`'s filter would have caught.
|
|
766
|
-
const
|
|
767
|
-
const takes = step => chosen() || cfg.shared.includes(step.key);
|
|
768
|
-
const wanted = plan(mine, list).filter(takes);
|
|
769
|
-
// TWO CHECKS, BECAUSE THEY END DIFFERENT THINGS. Losing the session —
|
|
770
|
-
// LAN switched off, the peer unpaired (`stillPaired`, above) — ends the
|
|
771
|
-
// round. From a deck the switch paired, an account unticked mid-round
|
|
772
|
-
// ends only that step: the next one may still be ticked, and the skipped
|
|
773
|
-
// row says why rather than vanishing.
|
|
774
|
-
const stillWanted = takes;
|
|
782
|
+
const wanted = plan(mine, list);
|
|
775
783
|
/** One login, asked for and opened: a `want` carrying its own proof that
|
|
776
784
|
* names the account (see transferChallenge), and the `have` opened under
|
|
777
785
|
* the additional data it was sealed with. The login, or why there is
|
|
@@ -790,15 +798,16 @@ export function createEngine({
|
|
|
790
798
|
return blob ? { blob } : { why: "could not open" };
|
|
791
799
|
};
|
|
792
800
|
let cut = false;
|
|
801
|
+
// Losing the session — LAN switched off, the peer unpaired — ends the
|
|
802
|
+
// round, checked before each question and again before each import. A
|
|
803
|
+
// tick changed here mid-round ends nothing: no step reads one.
|
|
793
804
|
for (const step of wanted) {
|
|
794
805
|
if (!stillPaired()) { cut = true; break; }
|
|
795
|
-
if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
|
|
796
806
|
const { blob, why } = await wantLogin(step);
|
|
797
807
|
if (!blob) { done.push({ ...step, ok: false, why }); continue; }
|
|
798
|
-
// Unpairing
|
|
799
|
-
//
|
|
808
|
+
// Unpairing or disabling LAN while export was in progress takes effect
|
|
809
|
+
// before the received credential touches disk.
|
|
800
810
|
if (!stillPaired()) { cut = true; break; }
|
|
801
|
-
if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
|
|
802
811
|
// A verdict rather than a boolean, because "refused" and "kept the
|
|
803
812
|
// slot it already has" are different things to tell somebody and the
|
|
804
813
|
// second one used to be reported as success. A bare `true` is still
|
|
@@ -825,11 +834,11 @@ export function createEngine({
|
|
|
825
834
|
// be asleep or on another network. Nothing new is exposed — the login
|
|
826
835
|
// came FROM the group, so the group has it.
|
|
827
836
|
//
|
|
828
|
-
// ONLY AN ADD,
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
837
|
+
// ONLY AN ADD, only from the local network, and only from a deck
|
|
838
|
+
// somebody here chose — see ticksOnArrival for why each. A healed slot
|
|
839
|
+
// was here already and its tick is somebody's decision either way; a
|
|
840
|
+
// tailnet reaches further than the person's own machines; and what a
|
|
841
|
+
// deck the switch paired brings is not passed on in this deck's name.
|
|
833
842
|
// The store can finish an import after the owner disabled LAN or
|
|
834
843
|
// revoked this peer. Keep the imported slot, but do not turn it into
|
|
835
844
|
// a newly shared credential on behalf of an obsolete transfer.
|
|
@@ -1026,9 +1035,6 @@ export function createEngine({
|
|
|
1026
1035
|
const card = id => ({
|
|
1027
1036
|
...heardOf(id),
|
|
1028
1037
|
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
1029
|
-
// And whether a switch said that yes rather than a person, which is
|
|
1030
|
-
// what the dialog needs to say which logins will not arrive from it.
|
|
1031
|
-
...(trustedPeer(cfg.trusted, id)?.auto ? { autoPaired: true } : {}),
|
|
1032
1038
|
});
|
|
1033
1039
|
for (const p of [...beacon.peers.values(), ...dials.rows()]) {
|
|
1034
1040
|
if (!stillListed(p, now())) continue;
|
package/src/server/lan-reach.mjs
CHANGED
|
@@ -536,6 +536,27 @@ export function reachability({ platform, probe, aliases = [], exePath = "", inbo
|
|
|
536
536
|
return windowsReach({ probe, aliases, exePath, inbound });
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
+
/**
|
|
540
|
+
* The verdict as the status route serves it: `said` is the last one taken, and
|
|
541
|
+
* `inboundAt` is when a connection from another machine last got in, as the
|
|
542
|
+
* engine knows it NOW.
|
|
543
|
+
*
|
|
544
|
+
* A VERDICT IS TAKEN AT MOST EVERY FIVE MINUTES (see refreshReach in
|
|
545
|
+
* lan-deck.mjs), and a complaint in it was true only of the moment it was
|
|
546
|
+
* taken. Reported from the Omarchy box this module's Linux block describes:
|
|
547
|
+
* its owner ran the two `ufw allow` lines, decks connected straight away —
|
|
548
|
+
* ufw applies a rule the moment it is added — and the panel went on saying
|
|
549
|
+
* "ufw is running here…" until the cached verdict expired, because it was
|
|
550
|
+
* taken before anything had connected. The connection that got in is the
|
|
551
|
+
* same measurement every platform here already puts above whatever it read,
|
|
552
|
+
* so it outranks a verdict taken before it, too. The answer is the one the
|
|
553
|
+
* next probe will give; every other answer is served as it was.
|
|
554
|
+
*/
|
|
555
|
+
export function servedReach(said, inboundAt) {
|
|
556
|
+
if (!said?.blocked || !inboundAt) return said ?? null;
|
|
557
|
+
return { blocked: false, why: "inbound seen", category: said.category ?? "", alias: said.alias ?? "" };
|
|
558
|
+
}
|
|
559
|
+
|
|
539
560
|
// ── the machine nothing can be asked about ──────────────────────────────────
|
|
540
561
|
//
|
|
541
562
|
// Every block above depends on the operating system answering a question.
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { cleanAlias, isAliasKey, lanEnabled, withAlias, withManualEntry } from "./deck-prefs.mjs";
|
|
11
11
|
import { heldPrefs } from "./prefs-state.mjs";
|
|
12
12
|
import { lanEngine, lastReach, refreshReach, tailnet } from "./lan-deck.mjs";
|
|
13
|
+
import { servedReach } from "./lan-reach.mjs";
|
|
13
14
|
import { IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
|
|
14
15
|
import { readBody, send } from "./http-io.mjs";
|
|
15
16
|
|
|
@@ -21,7 +22,9 @@ export function handleLanStatus(req, res) {
|
|
|
21
22
|
// Behind the answer, like the reach probe: the dialog's poll is what finds a
|
|
22
23
|
// Tailscale somebody installed while the deck was running.
|
|
23
24
|
if (lanEnabled(heldPrefs.current())) void tailnet.freshen(TAILNET_IDLE_MS);
|
|
24
|
-
|
|
25
|
+
// Read against what has got in since it was taken — see servedReach.
|
|
26
|
+
const status = lanEngine.status();
|
|
27
|
+
return send(res, 200, { ok: true, ...status, reach: servedReach(lastReach(), status.inboundAt) });
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -464,11 +464,12 @@ export function trustedPeer(trusted, fp) {
|
|
|
464
464
|
* told nothing changed.
|
|
465
465
|
*
|
|
466
466
|
* `auto` SAYS A SWITCH MADE THE PIN, not a person — the accept switch pressing
|
|
467
|
-
* accept for the owner. A pin that says so
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
467
|
+
* accept for the owner. A pin that says so is handed only what a person ticked
|
|
468
|
+
* here (see sharedWith), and what it brings is not ticked onward here (see
|
|
469
|
+
* ticksOnArrival in lan-engine.mjs); it places logins here like any paired
|
|
470
|
+
* deck, since 3.38.1 — see roundWith. A person's pin of the same key takes the
|
|
471
|
+
* mark away, because pressing accept, or pairing by invite, is the choice the
|
|
472
|
+
* switch only stood in for; a switch never puts it back on a pin a person made.
|
|
472
473
|
*/
|
|
473
474
|
export function addTrusted(trusted, entry) {
|
|
474
475
|
const list = Array.isArray(trusted) ? trusted : [];
|