ccdeck 3.27.7 → 3.28.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/bin/deck.js +8 -0
- package/dist/web/assets/{BrowserWatchModal-DiMnzuse.js → BrowserWatchModal-CMCvd-Zt.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-6HRc9h9q.js → UsageHistoryModal-BRY_LA4K.js} +1 -1
- package/dist/web/assets/index-C6jDY-YA.js +297 -0
- package/dist/web/assets/index-DbwzUUcd.css +1 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +21 -0
- package/dist/web/icon.svg +24 -0
- package/dist/web/index.html +20 -2
- package/dist/web/manifest.webmanifest +17 -0
- package/hook/hook.js +75 -4
- package/package.json +1 -1
- package/release-notes.json +2 -1
- package/src/server/app-manifest.mjs +55 -0
- package/src/server/deck-prefs.mjs +14 -6
- package/src/server/deck-probe.mjs +15 -1
- package/src/server/index.mjs +45 -5
- package/src/server/lan-engine.mjs +31 -7
- package/src/server/self-update.mjs +60 -11
- package/src/server/static-cache.mjs +1 -1
- package/src/server/way-back.mjs +104 -0
- package/dist/web/assets/index-Bv8dYdRM.css +0 -1
- package/dist/web/assets/index-CfHvDuGc.js +0 -297
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "/",
|
|
3
|
+
"name": "ccdeck",
|
|
4
|
+
"short_name": "ccdeck",
|
|
5
|
+
"description": "Live deck of Claude Code and Codex agents — tool calls, token spend and every subagent on one canvas.",
|
|
6
|
+
"start_url": "/",
|
|
7
|
+
"scope": "/",
|
|
8
|
+
"display": "standalone",
|
|
9
|
+
"background_color": "#0b0c10",
|
|
10
|
+
"theme_color": "#14161b",
|
|
11
|
+
"icons": [
|
|
12
|
+
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
13
|
+
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
|
14
|
+
{ "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
|
|
15
|
+
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
|
16
|
+
]
|
|
17
|
+
}
|
package/hook/hook.js
CHANGED
|
@@ -688,7 +688,13 @@ function forgetIfAbandoned(d, done) {
|
|
|
688
688
|
*/
|
|
689
689
|
function post(d, body, persists, done) {
|
|
690
690
|
let settled = false;
|
|
691
|
-
|
|
691
|
+
let sendDeadline = null;
|
|
692
|
+
const finish = (ok, why = null) => {
|
|
693
|
+
if (settled) return;
|
|
694
|
+
settled = true;
|
|
695
|
+
clearTimeout(sendDeadline);
|
|
696
|
+
done(ok, why);
|
|
697
|
+
};
|
|
692
698
|
// Whether the kernel has taken the whole body, and whether it was this
|
|
693
699
|
// process's deadline rather than the writer that ended the exchange — the
|
|
694
700
|
// two facts the last paragraph above sorts an error by.
|
|
@@ -707,7 +713,6 @@ function post(d, body, persists, done) {
|
|
|
707
713
|
res.resume();
|
|
708
714
|
finish(res.statusCode >= 200 && res.statusCode < 300);
|
|
709
715
|
});
|
|
710
|
-
req.on("finish", () => { sent = true; });
|
|
711
716
|
// `destroy()` on a timeout makes 'error' fire with ECONNRESET — the status
|
|
712
717
|
// line has not arrived, or it would have settled this already — so `timedOut`
|
|
713
718
|
// is what tells this process giving up apart from the writer hanging up.
|
|
@@ -730,9 +735,29 @@ function post(d, body, persists, done) {
|
|
|
730
735
|
// written the line — was never asked. The snapshot is the rule that paragraph
|
|
731
736
|
// states, taken at the only moment it is still true.
|
|
732
737
|
let sentByDeadline = false;
|
|
738
|
+
const expire = () => {
|
|
739
|
+
if (settled) return;
|
|
740
|
+
timedOut = true;
|
|
741
|
+
sentByDeadline = sent;
|
|
742
|
+
req.destroy();
|
|
743
|
+
};
|
|
733
744
|
req.on("error", () => finish(false, timedOut && sentByDeadline ? "deadline" : null));
|
|
734
|
-
req.on("timeout",
|
|
745
|
+
req.on("timeout", expire);
|
|
746
|
+
req.on("finish", () => {
|
|
747
|
+
sent = true;
|
|
748
|
+
clearTimeout(sendDeadline);
|
|
749
|
+
sendDeadline = null;
|
|
750
|
+
});
|
|
735
751
|
req.write(body);
|
|
752
|
+
// `http.request`'s timeout is an idle-socket timeout. While a large body is
|
|
753
|
+
// still being accepted into kernel buffers, those writes keep resetting it
|
|
754
|
+
// even if the peer application stopped reading. Bound the send phase itself
|
|
755
|
+
// so that a wedged writer can still hand the log to the next deck inside the
|
|
756
|
+
// hook's process cap. Once `finish` says the kernel took the whole body, this
|
|
757
|
+
// timer is cleared and the existing idle timeout remains the only deadline
|
|
758
|
+
// on a slow answer, preserving #1133's no-duplicate rule. (#1195)
|
|
759
|
+
sendDeadline = setTimeout(expire, POST_TIMEOUT_MS);
|
|
760
|
+
sendDeadline.unref?.();
|
|
736
761
|
req.end();
|
|
737
762
|
}
|
|
738
763
|
|
|
@@ -899,6 +924,32 @@ function discoverTargets(cwd, cb) {
|
|
|
899
924
|
normPathAsync(cwd, scan);
|
|
900
925
|
}
|
|
901
926
|
|
|
927
|
+
// JSON.parse can accept nesting that older supported Node releases cannot
|
|
928
|
+
// stringify again. Count structural nesting directly in the original text so
|
|
929
|
+
// the hook never depends on a runtime-specific recursion limit. Strings are
|
|
930
|
+
// skipped with their escapes, so braces inside tool output do not count.
|
|
931
|
+
function exceedsJsonDepth(text, limit = 1000) {
|
|
932
|
+
let depth = 0;
|
|
933
|
+
let quoted = false;
|
|
934
|
+
let escaped = false;
|
|
935
|
+
for (let i = 0; i < text.length; i++) {
|
|
936
|
+
const c = text[i];
|
|
937
|
+
if (quoted) {
|
|
938
|
+
if (escaped) escaped = false;
|
|
939
|
+
else if (c === "\\") escaped = true;
|
|
940
|
+
else if (c === '"') quoted = false;
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (c === '"') { quoted = true; continue; }
|
|
944
|
+
if (c === "{" || c === "[") {
|
|
945
|
+
if (++depth > limit) return true;
|
|
946
|
+
} else if (c === "}" || c === "]") {
|
|
947
|
+
depth--;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
|
|
902
953
|
function main() {
|
|
903
954
|
// Hard cap so a stuck server can never wedge the host CLI: this process ends
|
|
904
955
|
// itself rather than being killed. CAP_MS says what the budget is; this line
|
|
@@ -958,6 +1009,7 @@ function main() {
|
|
|
958
1009
|
process.stdin.on("end", () => {
|
|
959
1010
|
let parsed;
|
|
960
1011
|
try { parsed = JSON.parse(input); } catch { return process.exit(0); }
|
|
1012
|
+
const tooDeepToSerialize = exceedsJsonDepth(input);
|
|
961
1013
|
const cwd = parsed && parsed.cwd;
|
|
962
1014
|
if (!cwd) return process.exit(0);
|
|
963
1015
|
|
|
@@ -1008,7 +1060,26 @@ function main() {
|
|
|
1008
1060
|
// Read or a Grep result arrives whole in `tool_response` and is routinely
|
|
1009
1061
|
// megabytes.
|
|
1010
1062
|
if (parsed && typeof parsed === "object") parsed.cwd = resolvedCwd;
|
|
1011
|
-
|
|
1063
|
+
let taggedInput;
|
|
1064
|
+
try {
|
|
1065
|
+
if (tooDeepToSerialize) throw new RangeError("JSON nesting exceeds safe serialization depth");
|
|
1066
|
+
taggedInput = JSON.stringify(parsed);
|
|
1067
|
+
} catch {
|
|
1068
|
+
// JSON.parse accepts nesting much deeper than JSON.stringify can write
|
|
1069
|
+
// back out. Keep the event instead of losing it silently: all scalar
|
|
1070
|
+
// top-level metadata is safe to copy without walking the deep value,
|
|
1071
|
+
// which preserves the event/session/tool identity, canonical cwd and
|
|
1072
|
+
// provider while making the loss explicit to the deck. (#1180)
|
|
1073
|
+
const shallow = {};
|
|
1074
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1075
|
+
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
|
1076
|
+
shallow[key] = value;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
shallow.ccdeck_truncated = true;
|
|
1080
|
+
shallow.ccdeck_truncation_reason = "serialization-depth";
|
|
1081
|
+
taggedInput = JSON.stringify(shallow);
|
|
1082
|
+
}
|
|
1012
1083
|
|
|
1013
1084
|
// See proveTargets for what the other order cost. A record whose pid is
|
|
1014
1085
|
// merely alive has established nothing: it may be a deck that died and had
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.28.0",
|
|
4
4
|
"description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Whether this deck may be installed as an application, and why it usually may.
|
|
2
|
+
//
|
|
3
|
+
// WHAT THE MANIFEST BUYS. With `dist/web/manifest.webmanifest` served, Chrome
|
|
4
|
+
// and Edge put their own install control in the address bar and the deck gets a
|
|
5
|
+
// real icon: a Dock tile on macOS, a taskbar and Start entry on Windows, a
|
|
6
|
+
// `.desktop` file in the application launcher on Linux — which is the one that
|
|
7
|
+
// matters most, because a launcher entry works on Hyprland and Sway, where a
|
|
8
|
+
// system tray, the other way people ask for this, does not exist at all.
|
|
9
|
+
// Safari's File ▸ Add to Dock needs no manifest but honours the name and icons
|
|
10
|
+
// in one. None of that is UI this deck has to draw, nag about, or maintain.
|
|
11
|
+
//
|
|
12
|
+
// WHY IT IS NOT ALWAYS OFFERED, which is the whole of this file. An installed
|
|
13
|
+
// app is pinned to an ORIGIN, and an origin includes the port. So the icon is a
|
|
14
|
+
// promise about a number, and there is exactly one case where this deck cannot
|
|
15
|
+
// keep it: the random fallback.
|
|
16
|
+
//
|
|
17
|
+
// bin/deck.js asks for a port — 4317, or whatever `--port` said — and on a
|
|
18
|
+
// refused bind takes a RANDOM one out of 4318–4400 instead. That fallback is
|
|
19
|
+
// right and stays: 4317 is also the standard OTLP collector port, and Windows
|
|
20
|
+
// reserves contiguous TCP blocks for Hyper-V, WSL2 and Docker, so a deck that
|
|
21
|
+
// comes up on 4322 beats a deck that refuses to come up. But it draws a new
|
|
22
|
+
// number on every start. An app installed against one of those would be a
|
|
23
|
+
// broken shortcut by the next boot, and — because Chrome keys an installed app
|
|
24
|
+
// by origin — a SECOND icon would appear beside it, then a third. Somebody on a
|
|
25
|
+
// machine where 4317 is permanently taken would collect one dead tile per
|
|
26
|
+
// launch, and every one of them would look like the deck's fault.
|
|
27
|
+
//
|
|
28
|
+
// So the rule is not "is this 4317". It is "is this the port that was ASKED
|
|
29
|
+
// for", which is true for the default, true for an explicit `--port 4500` that
|
|
30
|
+
// bound, and false only when a bind was refused and a number was invented. A
|
|
31
|
+
// user who pins a port keeps their icon; a user who cannot have one never gets
|
|
32
|
+
// offered a shortcut that will not work tomorrow.
|
|
33
|
+
//
|
|
34
|
+
// The deck says nothing about any of this. An install control that is simply
|
|
35
|
+
// absent reads as a browser that has nothing to offer; a control that appeared
|
|
36
|
+
// and then explained itself would be a paragraph about port allocation in a
|
|
37
|
+
// place nobody asked a question.
|
|
38
|
+
|
|
39
|
+
/** The one path this is all about. Served out of dist/web like any other file. */
|
|
40
|
+
export const MANIFEST_PATH = "/manifest.webmanifest";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* May this deck be installed as an application?
|
|
44
|
+
*
|
|
45
|
+
* @param {object} [o]
|
|
46
|
+
* @param {number|null} [o.asked] The port the launcher wanted.
|
|
47
|
+
* @param {number|null} [o.bound] The port it actually got.
|
|
48
|
+
* @returns {boolean} False when the port was invented rather than chosen.
|
|
49
|
+
*/
|
|
50
|
+
export function offerManifest({ asked = null, bound = null } = {}) {
|
|
51
|
+
// Not yet listening, or a caller that never said — neither is a port anybody
|
|
52
|
+
// chose, and a manifest is the wrong thing to guess about.
|
|
53
|
+
if (!Number.isInteger(asked) || !Number.isInteger(bound)) return false;
|
|
54
|
+
return asked === bound;
|
|
55
|
+
}
|
|
@@ -72,8 +72,9 @@ export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.j
|
|
|
72
72
|
*/
|
|
73
73
|
export const DEFAULTS = Object.freeze({
|
|
74
74
|
notifications: false,
|
|
75
|
-
// The
|
|
76
|
-
//
|
|
75
|
+
// The Electron window can open on a new localhost port after an update.
|
|
76
|
+
// Its tour marker must outlive that origin; browser tabs still use their
|
|
77
|
+
// own localStorage marker and never read this preference.
|
|
77
78
|
tourSeen: false,
|
|
78
79
|
// Whether the deck may update itself: restart into code already on disk once
|
|
79
80
|
// it is idle, and — while nobody is looking — install a newer release and
|
|
@@ -86,7 +87,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
86
87
|
// write below names a mode. AGENTS_DECK_NO_LAN=1 keeps a deck off the network
|
|
87
88
|
// whatever this file says — see lanEnabled.
|
|
88
89
|
lan: Object.freeze({
|
|
89
|
-
enabled: true, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0,
|
|
90
|
+
enabled: true, name: "", secret: "", shared: [], manual: [], trusted: [], unpaired: [], port: 0,
|
|
90
91
|
// WHO PAIRS WITH WHOM, WITHOUT ANYBODY PRESSING ANYTHING. Asking is on, so
|
|
91
92
|
// two decks on one network find each other and send each other a request —
|
|
92
93
|
// which is what a person with three of their own machines wants and had to
|
|
@@ -178,8 +179,8 @@ function aliasesFrom(raw) {
|
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
/** One LAN section, coerced. Unknown keys dropped like everything else here,
|
|
181
|
-
* and
|
|
182
|
-
*
|
|
182
|
+
* and string lists forced to arrays of strings before anything compares them
|
|
183
|
+
* with account keys, addresses or fingerprints. */
|
|
183
184
|
function normaliseLan(raw) {
|
|
184
185
|
const src = raw && typeof raw === "object" ? raw : {};
|
|
185
186
|
const strings = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
|
|
@@ -206,6 +207,10 @@ function normaliseLan(raw) {
|
|
|
206
207
|
// kept, and absent is drawn as "before" rather than guessed.
|
|
207
208
|
...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
|
|
208
209
|
})),
|
|
210
|
+
// Fingerprints somebody explicitly unpaired. Kept separately from trusted
|
|
211
|
+
// because manual dial rows survive the action and must not silently rebuild
|
|
212
|
+
// trust on the next round or restart.
|
|
213
|
+
unpaired: strings(src.unpaired),
|
|
209
214
|
aliases: aliasesFrom(src.aliases),
|
|
210
215
|
// The port this deck listened on last time, so an address somebody typed on
|
|
211
216
|
// the other machine still works after a restart. It asked the OS for a new
|
|
@@ -566,7 +571,10 @@ async function save(mutate, home, deps) {
|
|
|
566
571
|
*/
|
|
567
572
|
export function publicPrefs(prefs) {
|
|
568
573
|
const p = normalise(prefs);
|
|
569
|
-
|
|
574
|
+
// unpaired is engine-authored state too. No page draws or edits it; keeping
|
|
575
|
+
// it out also means a future whole-prefs form cannot replay a stale unpair
|
|
576
|
+
// list over a decision the engine made after the form loaded.
|
|
577
|
+
const { secret, trusted, unpaired: _unpaired, ...lan } = p.lan;
|
|
570
578
|
return {
|
|
571
579
|
...p,
|
|
572
580
|
lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
|
|
@@ -70,7 +70,13 @@ export function challengeProof(token, nonce) {
|
|
|
70
70
|
export function challengeDeck(port, token) {
|
|
71
71
|
return new Promise(resolve => {
|
|
72
72
|
let settled = false;
|
|
73
|
-
|
|
73
|
+
let deadline;
|
|
74
|
+
const finish = ok => {
|
|
75
|
+
if (settled) return;
|
|
76
|
+
settled = true;
|
|
77
|
+
if (deadline) clearTimeout(deadline);
|
|
78
|
+
resolve(ok);
|
|
79
|
+
};
|
|
74
80
|
const nonce = randomBytes(16).toString("hex");
|
|
75
81
|
const want = challengeProof(token, nonce);
|
|
76
82
|
const req = httpRequest({
|
|
@@ -98,6 +104,14 @@ export function challengeDeck(port, token) {
|
|
|
98
104
|
});
|
|
99
105
|
req.on("error", () => finish(false));
|
|
100
106
|
req.on("timeout", () => req.destroy());
|
|
107
|
+
// `http.request`'s timeout is an idle timeout: a listener can keep this
|
|
108
|
+
// request alive forever by trickling bytes, and once a response has begun
|
|
109
|
+
// destroying the request does not reliably emit `error`. Bound the whole
|
|
110
|
+
// challenge instead, and settle before closing the socket. (#1179)
|
|
111
|
+
deadline = setTimeout(() => {
|
|
112
|
+
finish(false);
|
|
113
|
+
req.destroy();
|
|
114
|
+
}, DECK_CHALLENGE_TIMEOUT_MS);
|
|
101
115
|
req.end();
|
|
102
116
|
});
|
|
103
117
|
}
|
package/src/server/index.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import { MAC_FW, PROBE_PS, UFW_CONF, UFW_DEFAULTS, isActive, localAliases, reach
|
|
|
45
45
|
import { run } from "./exec.mjs";
|
|
46
46
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
47
47
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
48
|
+
import { MANIFEST_PATH, offerManifest } from "./app-manifest.mjs";
|
|
48
49
|
import { appendFailureStats, appendLogLine, appendsLanded, codexCwdInWorkspace, electWriters, emptyLog, flushAppends, foldsCase, writesCodexLog } from "./log-writer.mjs";
|
|
49
50
|
import { historySnapshot, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
|
|
50
51
|
import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
|
|
@@ -75,6 +76,11 @@ const MIME = {
|
|
|
75
76
|
".css": "text/css; charset=utf-8",
|
|
76
77
|
".json": "application/json; charset=utf-8",
|
|
77
78
|
".svg": "image/svg+xml",
|
|
79
|
+
// The type is the whole of how a browser recognises a manifest: served as
|
|
80
|
+
// application/octet-stream — which is what the fallback below hands anything
|
|
81
|
+
// unlisted — Chrome fetches it, declines to parse it, and offers no install,
|
|
82
|
+
// with nothing in the console that names the reason.
|
|
83
|
+
".webmanifest": "application/manifest+json; charset=utf-8",
|
|
78
84
|
".png": "image/png",
|
|
79
85
|
".jpg": "image/jpeg",
|
|
80
86
|
".woff2": "font/woff2",
|
|
@@ -4015,6 +4021,13 @@ const lanEngine = createEngine({
|
|
|
4015
4021
|
try { _prefs = await writePrefs({ lan: { trusted } }); }
|
|
4016
4022
|
catch (err) { console.error(`${PRODUCT}: lan sync could not save the pairing:`, err?.message ?? err); }
|
|
4017
4023
|
},
|
|
4024
|
+
// An explicit unpair must outlive the process too. Manual dial rows are kept
|
|
4025
|
+
// deliberately, so without this marker the next successful round could pin
|
|
4026
|
+
// the same fingerprint again without another press.
|
|
4027
|
+
onUnpaired: async unpaired => {
|
|
4028
|
+
try { _prefs = await writePrefs({ lan: { unpaired } }); }
|
|
4029
|
+
catch (err) { console.error(`${PRODUCT}: lan sync could not save the unpair decision:`, err?.message ?? err); }
|
|
4030
|
+
},
|
|
4018
4031
|
// An address this deck must keep dialling: the far end of a pairing that
|
|
4019
4032
|
// happened over an invite. Kept in prefs, because setPeers replaces the dial
|
|
4020
4033
|
// list wholesale on every settings write and a row that lives only in memory
|
|
@@ -4046,7 +4059,7 @@ const lanEngine = createEngine({
|
|
|
4046
4059
|
// one it is given, so this settles after one pass rather than looping.
|
|
4047
4060
|
//
|
|
4048
4061
|
// The key is handed over HERE rather than read back off `_prefs`, because
|
|
4049
|
-
// `applyLanPrefs` no longer round-trips the
|
|
4062
|
+
// `applyLanPrefs` no longer round-trips the fields the engine
|
|
4050
4063
|
// authors — see what it does and does not pass. This is the one caller
|
|
4051
4064
|
// that legitimately changes one of them, and it is holding the new value.
|
|
4052
4065
|
await applyLanPrefs({ secret });
|
|
@@ -4063,9 +4076,9 @@ const lanEngine = createEngine({
|
|
|
4063
4076
|
/**
|
|
4064
4077
|
* What prefs is entitled to tell the LAN engine, and what it is not.
|
|
4065
4078
|
*
|
|
4066
|
-
*
|
|
4067
|
-
* and `port` are written BY the engine, through `onTrust`,
|
|
4068
|
-
* `onPort` — prefs is where they are kept between runs, not where they are
|
|
4079
|
+
* FOUR FIELDS ARE THE ENGINE'S OWN AND ARE LOADED ONCE. `trusted`, `unpaired`,
|
|
4080
|
+
* `secret` and `port` are written BY the engine, through `onTrust`,
|
|
4081
|
+
* `onUnpaired`, `onIdentity` and `onPort` — prefs is where they are kept between runs, not where they are
|
|
4069
4082
|
* decided. Handing them back on every settings write round-trips the engine's
|
|
4070
4083
|
* live state through a module-level copy of a file, and that copy is stale for
|
|
4071
4084
|
* as long as one of those writes is queued.
|
|
@@ -4120,11 +4133,12 @@ export function lanApplyFields(prefs, { load = false, env = process.env } = {})
|
|
|
4120
4133
|
...page,
|
|
4121
4134
|
secret: lan.secret || "",
|
|
4122
4135
|
trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
|
|
4136
|
+
unpaired: Array.isArray(lan.unpaired) ? lan.unpaired : [],
|
|
4123
4137
|
port: lan.port || 0,
|
|
4124
4138
|
};
|
|
4125
4139
|
}
|
|
4126
4140
|
|
|
4127
|
-
/** Whether the engine has already been handed the
|
|
4141
|
+
/** Whether the engine has already been handed the fields it authors.
|
|
4128
4142
|
* Reset by `startServer`, because a boot is what reads them off the file. */
|
|
4129
4143
|
let _lanLoaded = false;
|
|
4130
4144
|
|
|
@@ -7480,6 +7494,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7480
7494
|
// background quota poll hit a network error. Answer the request instead.
|
|
7481
7495
|
const guard = (p, res) => Promise.resolve(p).catch(err => sendInternalError(res, err));
|
|
7482
7496
|
|
|
7497
|
+
// Which of the eleven candidates below this deck ended up on, set the moment
|
|
7498
|
+
// one of them binds. Declared here rather than beside the loop that fills it
|
|
7499
|
+
// so it is above the route that reads it — see the manifest gate.
|
|
7500
|
+
let boundPort = null;
|
|
7501
|
+
|
|
7483
7502
|
const route = (req, res) => {
|
|
7484
7503
|
const url = requestUrl(req.url);
|
|
7485
7504
|
// Unparseable request target. Nothing below can route it, and throwing here
|
|
@@ -7653,6 +7672,20 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7653
7672
|
return send(res, 404, { error: "not found" });
|
|
7654
7673
|
}
|
|
7655
7674
|
|
|
7675
|
+
// THE MANIFEST IS WITHDRAWN WHEN THE PORT WAS INVENTED, not served and
|
|
7676
|
+
// then apologised for. An installed app is pinned to an origin and an
|
|
7677
|
+
// origin includes the port, so offering one from a random fallback port
|
|
7678
|
+
// promises a shortcut that the next boot breaks — and leaves a dead tile
|
|
7679
|
+
// behind for every launch, because the browser keys an app by origin. See
|
|
7680
|
+
// app-manifest.mjs, which is where the rule and its reasoning live.
|
|
7681
|
+
//
|
|
7682
|
+
// A 404 rather than a route that answers `{}`: an unparseable manifest is
|
|
7683
|
+
// a console error on every page load, and "absent" is exactly what this
|
|
7684
|
+
// means.
|
|
7685
|
+
if (url.pathname === MANIFEST_PATH && !offerManifest({ asked: port, bound: boundPort })) {
|
|
7686
|
+
return send(res, 404, { error: "not found" });
|
|
7687
|
+
}
|
|
7688
|
+
|
|
7656
7689
|
if (req.method === "GET") return serveStatic(req, res, url);
|
|
7657
7690
|
send(res, 405, { error: "method not allowed" });
|
|
7658
7691
|
};
|
|
@@ -7692,6 +7725,13 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7692
7725
|
for (const candidate of candidates) {
|
|
7693
7726
|
try {
|
|
7694
7727
|
await tryListen(server, candidate, host);
|
|
7728
|
+
// What actually bound, read by the manifest gate above. `address()`
|
|
7729
|
+
// rather than `candidate`, and the difference is the whole point of the
|
|
7730
|
+
// gate: `port: 0` means "any ephemeral port", so the candidate and the
|
|
7731
|
+
// request agree at 0 while the deck is listening on a number nobody chose
|
|
7732
|
+
// and that will differ on the next boot. Every test in this suite boots
|
|
7733
|
+
// that way. Comparing the candidate would hand those decks a manifest.
|
|
7734
|
+
boundPort = server.address()?.port ?? candidate;
|
|
7695
7735
|
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
7696
7736
|
if (codex) startCodexWatcher(workspace);
|
|
7697
7737
|
// What a session is producing between its tool calls. Claude only — it
|
|
@@ -213,7 +213,7 @@ export function ticksOnArrival(step, via) {
|
|
|
213
213
|
|
|
214
214
|
export function createEngine({
|
|
215
215
|
readAccounts, exportAccount, importAccount,
|
|
216
|
-
onChange, onError, onIdentity, onPort, onTrust, onDial, onShared, now = Date.now,
|
|
216
|
+
onChange, onError, onIdentity, onPort, onTrust, onUnpaired, onDial, onShared, now = Date.now,
|
|
217
217
|
/**
|
|
218
218
|
* The UDP socket the beacon shouts through, injectable for the same reason
|
|
219
219
|
* lan-socket exposes it — and for one more that only showed up in use.
|
|
@@ -262,7 +262,7 @@ export function createEngine({
|
|
|
262
262
|
routes = null,
|
|
263
263
|
} = {}) {
|
|
264
264
|
let cfg = {
|
|
265
|
-
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
|
|
265
|
+
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], unpaired: [], port: 0,
|
|
266
266
|
autoAsk: true, autoAccept: true, aliases: {},
|
|
267
267
|
// Tell paired decks which shared account this one is on — see currentFor.
|
|
268
268
|
shareActive: true,
|
|
@@ -427,6 +427,21 @@ export function createEngine({
|
|
|
427
427
|
* — and always is on a deck with no Tailscale reader. */
|
|
428
428
|
const routeTo = addr => routeOf(tailnet?.snapshot?.() ?? null, addr);
|
|
429
429
|
|
|
430
|
+
/** A pairing somebody explicitly removed. Unlike `declined`, this survives a
|
|
431
|
+
* restart because the old dial row survives too; forgetting the decision
|
|
432
|
+
* would let that row silently recreate the pairing on the next round. */
|
|
433
|
+
const wasUnpaired = fp => Array.isArray(cfg.unpaired) && cfg.unpaired.includes(fp);
|
|
434
|
+
const markUnpaired = (fp, value) => {
|
|
435
|
+
const before = Array.isArray(cfg.unpaired) ? cfg.unpaired : [];
|
|
436
|
+
const next = value
|
|
437
|
+
? (before.includes(fp) ? before : [...before, fp])
|
|
438
|
+
: before.filter(x => x !== fp);
|
|
439
|
+
if (next.length === before.length && next.every((x, i) => x === before[i])) return false;
|
|
440
|
+
cfg = { ...cfg, unpaired: next };
|
|
441
|
+
onUnpaired?.(next);
|
|
442
|
+
return true;
|
|
443
|
+
};
|
|
444
|
+
|
|
430
445
|
/** The two permissions that answer for one route. */
|
|
431
446
|
const asksOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
|
|
432
447
|
const saysYesOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
|
|
@@ -618,7 +633,10 @@ export function createEngine({
|
|
|
618
633
|
const via = route ? "tailscale" : "lan";
|
|
619
634
|
const own = !!route?.own;
|
|
620
635
|
pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
|
|
621
|
-
if (saysYesOn(via) && (via === "lan" || own)) {
|
|
636
|
+
if (!wasUnpaired(entry.fp) && saysYesOn(via) && (via === "lan" || own)) {
|
|
637
|
+
engine?.accept(entry.fp, { byHand: false });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
622
640
|
if (!had) onChange?.();
|
|
623
641
|
};
|
|
624
642
|
|
|
@@ -740,7 +758,7 @@ export function createEngine({
|
|
|
740
758
|
// A deck we DO have a pin for was checked before this line: connectToPeer
|
|
741
759
|
// was given expectPub and refuses a different key at that address.
|
|
742
760
|
if (!trustedPeer(cfg.trusted, conn.peerFp)) {
|
|
743
|
-
if (!peer.typed) {
|
|
761
|
+
if (!peer.typed || wasUnpaired(conn.peerFp)) {
|
|
744
762
|
// The same row the listener's own `onPending` draws, from the other
|
|
745
763
|
// direction: this deck dialled rather than being dialled, and the
|
|
746
764
|
// handshake it just finished is the same evidence either way — a real
|
|
@@ -996,11 +1014,11 @@ export function createEngine({
|
|
|
996
1014
|
const ask = c => ({ lan: !!c.autoAsk, tailscale: !!c.tailscale && c.tailscaleAsk !== false });
|
|
997
1015
|
const turnedOn = (f, via) => !f(was)[via] && f(cfg)[via];
|
|
998
1016
|
const mayAnswer = p => (p.via === "tailscale" ? turnedOn(yes, "tailscale") && p.own : turnedOn(yes, "lan"));
|
|
999
|
-
for (const [fp, p] of [...pending]) if (mayAnswer(p)) this.accept(fp);
|
|
1017
|
+
for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) this.accept(fp, { byHand: false });
|
|
1000
1018
|
// The same for the other direction: switching `ask` on with four machines
|
|
1001
1019
|
// already listed asks those four.
|
|
1002
1020
|
const mayAsk = p => (p.via === "tailscale" ? turnedOn(ask, "tailscale") && p.own : turnedOn(ask, "lan"));
|
|
1003
|
-
for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub && mayAsk(p)) this.accept(fp, { byHand: false });
|
|
1021
|
+
for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !wasUnpaired(fp) && !p.pub && mayAsk(p)) this.accept(fp, { byHand: false });
|
|
1004
1022
|
// OFF MEANS THE TAILNET GOES QUIET HERE: nobody heard over it is offered,
|
|
1005
1023
|
// and syncTailnet stops the reads. Decks already paired stay paired.
|
|
1006
1024
|
if (was.tailscale && !cfg.tailscale) {
|
|
@@ -1037,6 +1055,7 @@ export function createEngine({
|
|
|
1037
1055
|
onInviteUsed: entry => {
|
|
1038
1056
|
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
|
|
1039
1057
|
cfg = { ...cfg, trusted: list };
|
|
1058
|
+
markUnpaired(entry.fp, false);
|
|
1040
1059
|
invite = null;
|
|
1041
1060
|
// AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
|
|
1042
1061
|
// deck with no way to reach it: an inbound connection puts nothing in
|
|
@@ -1123,7 +1142,7 @@ export function createEngine({
|
|
|
1123
1142
|
// Over the tailnet only a machine on this person's own account is
|
|
1124
1143
|
// asked unprompted; any other is a row for somebody to decide on.
|
|
1125
1144
|
const mayAsk = asksOn(entry.via) && (entry.via !== "tailscale" || own);
|
|
1126
|
-
if (mayAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
|
|
1145
|
+
if (mayAsk && !had && !declined.has(entry.fp) && !wasUnpaired(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
|
|
1127
1146
|
// Only a deck that is new to us is news. A beacon every thirty
|
|
1128
1147
|
// seconds from one already on the list is not a reason to redraw.
|
|
1129
1148
|
if (!had) onChange?.();
|
|
@@ -1229,6 +1248,7 @@ export function createEngine({
|
|
|
1229
1248
|
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
|
|
1230
1249
|
});
|
|
1231
1250
|
cfg = { ...cfg, trusted: list };
|
|
1251
|
+
markUnpaired(conn.peerFp, false);
|
|
1232
1252
|
this.addPeer(at.addr, at.port);
|
|
1233
1253
|
onDial?.(`${at.addr}:${at.port}`);
|
|
1234
1254
|
learned.set(`${at.addr}:${at.port}`, { fp: conn.peerFp, name: conn.peerName || inv.name });
|
|
@@ -1262,6 +1282,7 @@ export function createEngine({
|
|
|
1262
1282
|
const heard = strangers.get(fp) ?? null;
|
|
1263
1283
|
const seen = asked ?? heard;
|
|
1264
1284
|
if (!seen) return null;
|
|
1285
|
+
if (wasUnpaired(fp) && !byHand) return null;
|
|
1265
1286
|
// TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
|
|
1266
1287
|
//
|
|
1267
1288
|
// A deck that ASKED finished a handshake, so it held the private half of
|
|
@@ -1276,6 +1297,7 @@ export function createEngine({
|
|
|
1276
1297
|
// is the same two presses, in the other order.
|
|
1277
1298
|
if (!seen.pub) {
|
|
1278
1299
|
if (!seen.addr || !seen.port) return null;
|
|
1300
|
+
markUnpaired(fp, false);
|
|
1279
1301
|
// `byHand` IS WHAT THE ROW WILL BE ALLOWED TO DO LATER. A press here is
|
|
1280
1302
|
// a person naming a machine, so the row may be pinned on the round that
|
|
1281
1303
|
// reaches it. `autoAsk` reaches this same line with byHand false — the
|
|
@@ -1288,6 +1310,7 @@ export function createEngine({
|
|
|
1288
1310
|
}
|
|
1289
1311
|
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
|
|
1290
1312
|
cfg = { ...cfg, trusted: list };
|
|
1313
|
+
markUnpaired(fp, false);
|
|
1291
1314
|
pending.delete(fp);
|
|
1292
1315
|
strangers.delete(fp);
|
|
1293
1316
|
onTrust?.(list);
|
|
@@ -1338,6 +1361,7 @@ export function createEngine({
|
|
|
1338
1361
|
const list = dropTrusted(cfg.trusted, fp);
|
|
1339
1362
|
if (list.length === cfg.trusted.length) return false;
|
|
1340
1363
|
cfg = { ...cfg, trusted: list };
|
|
1364
|
+
markUnpaired(fp, true);
|
|
1341
1365
|
onTrust?.(list);
|
|
1342
1366
|
onChange?.();
|
|
1343
1367
|
return true;
|
|
@@ -132,9 +132,54 @@ export function mayAskNpm({ now, lastAskAt }) {
|
|
|
132
132
|
|
|
133
133
|
// ── version comparison ───────────────────────────────────────────────────────
|
|
134
134
|
|
|
135
|
-
/**
|
|
136
|
-
* segments count as 0
|
|
137
|
-
* "1.9.0"
|
|
135
|
+
/** Numeric-segment compare of one version's parts: -1, 0 or 1. Non-numeric
|
|
136
|
+
* segments count as 0 and missing segments pad with 0, so "1.30" sorts before
|
|
137
|
+
* "1.30.1" and "1.9.0" before "1.10.0". */
|
|
138
|
+
function cmpParts(x, y) {
|
|
139
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
140
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
141
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A version split at the FIRST `-`: the release on one side, the prerelease
|
|
147
|
+
* identifiers on the other, or null when there is no prerelease at all.
|
|
148
|
+
*
|
|
149
|
+
* `+` stays inside the release half rather than starting a third part, which
|
|
150
|
+
* keeps build metadata reading exactly as it read before — `1.0.0+build.7`
|
|
151
|
+
* segments to [1,0,0,0,7] and therefore sorts above `1.0.0`. That is inherited
|
|
152
|
+
* behaviour rather than a design, this repo has never published one, and a
|
|
153
|
+
* change to it belongs in its own issue. */
|
|
154
|
+
function versionParts(v) {
|
|
155
|
+
const nums = (s) => s.split(/[.+]/).map(n => parseInt(n, 10)).map(n => Number.isNaN(n) ? 0 : n);
|
|
156
|
+
const dash = v.indexOf("-");
|
|
157
|
+
if (dash === -1) return { release: nums(v), pre: null };
|
|
158
|
+
return { release: nums(v.slice(0, dash)), pre: nums(v.slice(dash + 1)) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when `a` sorts before `b`.
|
|
162
|
+
*
|
|
163
|
+
* A PRERELEASE SORTS BELOW THE RELEASE IT PRECEDES (#976), which is semver's
|
|
164
|
+
* rule and was not this function's. Splitting on `[.\-+]` and mapping
|
|
165
|
+
* non-numeric segments to 0 made `3.23.0-rc.1` segment to [3,23,0,0,1] — five
|
|
166
|
+
* numbers against the release's three, so it compared ABOVE `3.23.0` and the
|
|
167
|
+
* release compared below the candidate it was meant to supersede:
|
|
168
|
+
*
|
|
169
|
+
* isOlder("3.23.0-rc.1", "3.23.0") = false ← the release never looked newer
|
|
170
|
+
* isOlder("3.23.0", "3.23.0-rc.1") = true ← and the deck would go backwards
|
|
171
|
+
*
|
|
172
|
+
* What that costs is not a cosmetic ordering. `autoUpdate` defaults on, so a
|
|
173
|
+
* deck that has ended up on a prerelease — installed by hand by a tester, or
|
|
174
|
+
* by the dist-tag accident publish.yml now prevents — produces no upgrade
|
|
175
|
+
* notice when the real release ships, shows nothing in the banner, and has no
|
|
176
|
+
* way to come back off it from inside the product. A bad release can be
|
|
177
|
+
* followed by a good one; a fleet that cannot SEE the good one cannot be
|
|
178
|
+
* rescued by publishing it, which is why this half matters even with the
|
|
179
|
+
* dist-tag half in place.
|
|
180
|
+
*
|
|
181
|
+
* Ordinary numeric ordering is untouched — the release halves are compared
|
|
182
|
+
* exactly as before — and so is build metadata; see versionParts.
|
|
138
183
|
*
|
|
139
184
|
* cswap-install.mjs had this written out a second time, without the type guard
|
|
140
185
|
* below, and imports it from here now (#374). The two bodies were identical:
|
|
@@ -143,16 +188,20 @@ export function mayAskNpm({ now, lastAskAt }) {
|
|
|
143
188
|
* and threw a TypeError there. Nothing could reach that call with a non-string
|
|
144
189
|
* (both arguments are behind `typeof v === "string"` checks at the one call
|
|
145
190
|
* site), so this is the copy with a test behind it absorbing the one without,
|
|
146
|
-
* not a bug fix.
|
|
191
|
+
* not a bug fix. That sweep is now one-sided on prerelease pairs and
|
|
192
|
+
* duplicated-helpers.test.ts says which, rather than asserting an equality
|
|
193
|
+
* that has deliberately stopped holding. */
|
|
147
194
|
export function isOlder(a, b) {
|
|
148
195
|
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return
|
|
196
|
+
const x = versionParts(a), y = versionParts(b);
|
|
197
|
+
const release = cmpParts(x.release, y.release);
|
|
198
|
+
if (release !== 0) return release < 0;
|
|
199
|
+
// Same release. A version carrying a prerelease is below the plain one, and
|
|
200
|
+
// never the other way round; two prereleases of one release compare on their
|
|
201
|
+
// own identifiers, so rc.2 is above rc.1.
|
|
202
|
+
if (x.pre && !y.pre) return true;
|
|
203
|
+
if (!x.pre || !y.pre) return false;
|
|
204
|
+
return cmpParts(x.pre, y.pre) < 0;
|
|
156
205
|
}
|
|
157
206
|
|
|
158
207
|
// ── what is on disk ──────────────────────────────────────────────────────────
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
|
|
12
12
|
|
|
13
13
|
/** The types worth compressing; images and fonts are compressed already. */
|
|
14
|
-
const COMPRESSIBLE = new Set([".html", ".js", ".mjs", ".css", ".json", ".svg", ".map"]);
|
|
14
|
+
const COMPRESSIBLE = new Set([".html", ".js", ".mjs", ".css", ".json", ".svg", ".map", ".webmanifest"]);
|
|
15
15
|
|
|
16
16
|
/** Vite's output under assets/: `<name>-<hash>.<ext>`, the hash eight or more
|
|
17
17
|
* characters of base64url. */
|