ccdeck 3.32.2 → 3.33.1
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 +14 -12
- package/bin/agent-dag.js +6 -0
- package/bin/cli/help.js +2 -0
- package/bin/cli/one-shot.js +32 -13
- package/bin/cli/second-start.js +1 -0
- package/bin/cli/startup.js +34 -0
- package/bin/cli/uninstall.js +47 -2
- package/bin/deck.js +12 -1
- package/dist/web/assets/BrowserWatchModal-BTNOQdp5.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CIDpgHTW.js +1 -0
- package/dist/web/assets/index-BIANlQ9A.css +1 -0
- package/dist/web/assets/index-D7ddxIpC.js +299 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +36 -1
- package/src/server/account-projects.mjs +11 -2
- package/src/server/args.mjs +12 -2
- package/src/server/auto-update.mjs +14 -1
- package/src/server/best-of-nostalgia.mjs +2 -1
- package/src/server/block-notify.mjs +114 -11
- package/src/server/browser-history.mjs +81 -4
- package/src/server/browser-presence.mjs +98 -2
- package/src/server/browser-react.mjs +39 -4
- package/src/server/browser-watch-seen.mjs +35 -1
- package/src/server/browser-watch.mjs +105 -18
- package/src/server/cafe-music-bgm.mjs +2 -1
- package/src/server/ccusage-install.mjs +8 -4
- package/src/server/ccusage.mjs +3 -1
- package/src/server/claude-accounts.mjs +38 -16
- package/src/server/codex-dir.mjs +8 -6
- package/src/server/codex-quota.mjs +4 -2
- package/src/server/codex-usage.mjs +93 -38
- package/src/server/codex-watch.mjs +150 -18
- package/src/server/cswap-auto-loop.mjs +15 -2
- package/src/server/cswap-auto-readers.mjs +10 -4
- package/src/server/cswap-auto.mjs +6 -6
- package/src/server/cswap-install.mjs +118 -3
- package/src/server/deck-prefs.mjs +4 -0
- package/src/server/event-pipeline.mjs +3 -0
- package/src/server/event-ring.mjs +2 -2
- package/src/server/good-life-radio.mjs +2 -1
- package/src/server/index.mjs +3 -2
- package/src/server/installer.mjs +62 -12
- package/src/server/lan-beacon.mjs +6 -4
- package/src/server/lan-call.mjs +4 -0
- package/src/server/lan-dials.mjs +10 -1
- package/src/server/lan-engine.mjs +221 -44
- package/src/server/lan-requests.mjs +88 -5
- package/src/server/lan-round-record.mjs +21 -4
- package/src/server/lan-socket.mjs +105 -15
- package/src/server/lan-sync.mjs +18 -4
- package/src/server/lifecycle.mjs +21 -2
- package/src/server/live-radio-mix.mjs +2 -1
- package/src/server/log-replay.mjs +55 -24
- package/src/server/login-service.mjs +38 -3
- package/src/server/metrics-run.mjs +5 -3
- package/src/server/network-sampler.mjs +25 -2
- package/src/server/npm-upgrade.mjs +8 -1
- package/src/server/npx.mjs +32 -5
- package/src/server/process-list.mjs +103 -13
- package/src/server/purge-key.mjs +52 -24
- package/src/server/request-gates.mjs +47 -14
- package/src/server/retire-sound-hook.mjs +56 -9
- package/src/server/ring-bounds.mjs +10 -0
- package/src/server/running-deck.mjs +72 -2
- package/src/server/session-enrichment.mjs +40 -8
- package/src/server/stop-deck.mjs +13 -4
- package/src/server/system-metrics.mjs +2 -2
- package/src/server/transcript-scan.mjs +27 -6
- package/dist/web/assets/BrowserWatchModal-bx_k4o-J.js +0 -1
- package/dist/web/assets/UsageHistoryModal-Do8S0UPG.js +0 -1
- package/dist/web/assets/index-DKn7nc15.css +0 -1
- package/dist/web/assets/index-hGO2DTdi.js +0 -299
|
@@ -27,7 +27,7 @@ import { inviteProof, inviteProofBack } from "./lan-invite.mjs";
|
|
|
27
27
|
// The line both halves speak — the reader, the writer, the cap on a frame and
|
|
28
28
|
// the deadline on a handshake — is lan-lines.mjs's. Re-exported, so what
|
|
29
29
|
// imports it from here still does.
|
|
30
|
-
import { frameReader, sendFrame, HANDSHAKE_MS } from "./lan-lines.mjs";
|
|
30
|
+
import { frameReader, sendFrame, HANDSHAKE_MS, MAX_FRAME_BYTES } from "./lan-lines.mjs";
|
|
31
31
|
export { frameReader, sendFrame, HANDSHAKE_MS, MAX_FRAME_BYTES } from "./lan-lines.mjs";
|
|
32
32
|
// The calling half — dialling, proving, and reading a refusal — is
|
|
33
33
|
// lan-call.mjs's. Re-exported, so what dials through this file still does.
|
|
@@ -84,6 +84,32 @@ export const MAX_SOCKETS_PER_HOST = 4;
|
|
|
84
84
|
*/
|
|
85
85
|
export const IDLE_MS = 30_000;
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* How many questions from one caller may wait behind the one being answered
|
|
89
|
+
* (#1739).
|
|
90
|
+
*
|
|
91
|
+
* A round asks one question and reads its answer before it asks the next —
|
|
92
|
+
* askOver in lan-engine.mjs — so a deck of any version never has a frame
|
|
93
|
+
* waiting here at all. The listener used to start its handler for every frame
|
|
94
|
+
* in a chunk at once, and a paired caller pipelining thousands of `manifest`
|
|
95
|
+
* questions had every answer built and queued in this process. Four is slack,
|
|
96
|
+
* not a queue: past it the caller is not asking the way any deck asks, and the
|
|
97
|
+
* connection is refused.
|
|
98
|
+
*/
|
|
99
|
+
export const MAX_WAITING_FRAMES = 4;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The most answers a caller may leave unread before its connection is let go
|
|
103
|
+
* (#1739).
|
|
104
|
+
*
|
|
105
|
+
* An answer is one frame, at most MAX_FRAME_BYTES, and a round reads each one
|
|
106
|
+
* before it asks again — so even on a slow link one frame is the most an
|
|
107
|
+
* honest caller has sitting in this process's buffer. Four of them unread is a
|
|
108
|
+
* caller that has stopped reading, and every answer after that would be held
|
|
109
|
+
* here, in memory, for as long as it cared to keep asking.
|
|
110
|
+
*/
|
|
111
|
+
export const MAX_UNREAD_BYTES = 4 * MAX_FRAME_BYTES;
|
|
112
|
+
|
|
87
113
|
/**
|
|
88
114
|
* The answering half.
|
|
89
115
|
*
|
|
@@ -170,6 +196,10 @@ export function createSyncServer({
|
|
|
170
196
|
let server = null;
|
|
171
197
|
let cancelStart = null;
|
|
172
198
|
const live = new Set();
|
|
199
|
+
/** The challenge each live connection was sent. A caller holding this deck's
|
|
200
|
+
* own key is answered (see auth), and this is how the deck tells its own
|
|
201
|
+
* listener answering it from another machine that holds the same key. */
|
|
202
|
+
const challenges = new Set();
|
|
173
203
|
|
|
174
204
|
/** Which host a socket came from, in one spelling. Node reports an IPv4 peer
|
|
175
205
|
* on a dual-stack listener as `::ffff:127.0.0.1`, and two spellings of one
|
|
@@ -242,6 +272,12 @@ export function createSyncServer({
|
|
|
242
272
|
* this end did. */
|
|
243
273
|
const sealedChannel = () =>
|
|
244
274
|
(sealsFrames(myChallenge) && sealsFrames(theirChallenge) ? frameChannel(key, "listener") : null);
|
|
275
|
+
/** Frames from the authenticated caller waiting behind the one being
|
|
276
|
+
* answered, and whether one is — see MAX_WAITING_FRAMES. Only a handler
|
|
277
|
+
* that returns a promise holds the line; one that answers there and then
|
|
278
|
+
* is done before the next frame is read, as it always was. */
|
|
279
|
+
const waiting = [];
|
|
280
|
+
let answering = false;
|
|
245
281
|
|
|
246
282
|
// Armed before the first byte is read, and cleared only by a completed
|
|
247
283
|
// handshake. A socket that connects and says nothing is the cheapest
|
|
@@ -249,7 +285,8 @@ export function createSyncServer({
|
|
|
249
285
|
const deadline = setTimeout(() => { if (!authed) sock.destroy(); }, handshakeMs);
|
|
250
286
|
deadline.unref?.();
|
|
251
287
|
|
|
252
|
-
|
|
288
|
+
challenges.add(myChallenge);
|
|
289
|
+
const done = () => { clearTimeout(deadline); live.delete(sock); challenges.delete(myChallenge); };
|
|
253
290
|
sock.on("close", done);
|
|
254
291
|
sock.on("error", err => { done(); onError?.("peer", err); });
|
|
255
292
|
|
|
@@ -391,6 +428,16 @@ export function createSyncServer({
|
|
|
391
428
|
// was made when this socket opened and has never been sent before.
|
|
392
429
|
if (!proofOk(want, msg.proof)) return refuse("bad proof");
|
|
393
430
|
|
|
431
|
+
// A CALLER HOLDING THIS DECK'S OWN KEY, which the proof above just
|
|
432
|
+
// showed. A deck that hears its own fingerprint from another process
|
|
433
|
+
// dials the address the beacon named to find out whether another machine
|
|
434
|
+
// really holds its key (see idClash in lan-engine.mjs), and this is the
|
|
435
|
+
// answer: our proof back, which only a holder of the key can make, and
|
|
436
|
+
// nothing more. Never a request to accept — that would pin this deck's
|
|
437
|
+
// own key — and nothing is served, because every handler asks the
|
|
438
|
+
// trusted list and this deck's own fingerprint is never on it.
|
|
439
|
+
if (peerFp === fp) { welcome({}); return; }
|
|
440
|
+
|
|
394
441
|
// WHO IS THIS, and it is the only question left. The handshake proves
|
|
395
442
|
// they hold the key they claimed; the trusted list says whether anybody
|
|
396
443
|
// here ever agreed to talk to it.
|
|
@@ -495,6 +542,48 @@ export function createSyncServer({
|
|
|
495
542
|
welcome({});
|
|
496
543
|
};
|
|
497
544
|
|
|
545
|
+
/** Hand one authenticated frame to the handler, and the next waiting one
|
|
546
|
+
* once it is done. A throw ends the connection, as it did when the frame
|
|
547
|
+
* reader called the handler itself; so does a promise that rejects, which
|
|
548
|
+
* nothing used to catch. */
|
|
549
|
+
const answer = frame => {
|
|
550
|
+
let out;
|
|
551
|
+
try { out = handlers?.(frame, answerCtx()); }
|
|
552
|
+
catch (err) { if (!refused) refuse("bad frame", err); return; }
|
|
553
|
+
if (!out || typeof out.then !== "function") return answerNext();
|
|
554
|
+
answering = true;
|
|
555
|
+
out.then(null, err => { if (!refused) refuse("bad frame", err); })
|
|
556
|
+
.finally(() => { answering = false; answerNext(); });
|
|
557
|
+
};
|
|
558
|
+
const answerNext = () => {
|
|
559
|
+
if (refused || sock.destroyed || !waiting.length) { waiting.length = 0; return; }
|
|
560
|
+
answer(waiting.shift());
|
|
561
|
+
};
|
|
562
|
+
/** What the handler is handed with each frame. */
|
|
563
|
+
const answerCtx = () => ({
|
|
564
|
+
sock, peerFp, key, sealed: !!chan,
|
|
565
|
+
// Where this deck dials the caller BACK. A deck that only ever calls in
|
|
566
|
+
// is one this deck holds no address for, so it could receive nothing —
|
|
567
|
+
// accounts move only toward the deck that dials (see roundWith). The
|
|
568
|
+
// address the caller connected from, and the port it said it listens
|
|
569
|
+
// on, are a dialable pair the engine can add so the next round reaches
|
|
570
|
+
// it. `peerAddr` is the source of this very connection; `peerPort` came
|
|
571
|
+
// from the hello, not the ephemeral source port.
|
|
572
|
+
peerAddr: from(sock),
|
|
573
|
+
peerPort,
|
|
574
|
+
send: obj => {
|
|
575
|
+
sendFrame(sock, chan ? chan.wrap(obj) : obj);
|
|
576
|
+
// AND NOT INTO MEMORY FOR A CALLER THAT DOES NOT READ (#1739). The
|
|
577
|
+
// write never looked at what was already queued; past
|
|
578
|
+
// MAX_UNREAD_BYTES the caller has stopped reading, and it is let go
|
|
579
|
+
// rather than answered into this process's heap.
|
|
580
|
+
if (!sock.destroyed && sock.writableLength > MAX_UNREAD_BYTES) {
|
|
581
|
+
onError?.("frame", new Error("answers left unread"));
|
|
582
|
+
sock.destroy();
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
|
|
498
587
|
sock.on("data", frameReader(msg => {
|
|
499
588
|
// NOTHING AFTER A REFUSAL. The reader hands over every frame in a chunk,
|
|
500
589
|
// and `refuse` destroys the socket without stopping it. So a `hello`
|
|
@@ -533,19 +622,16 @@ export function createSyncServer({
|
|
|
533
622
|
// as it stands instead; that fallback would be the downgrade.
|
|
534
623
|
const frame = chan ? chan.unwrap(msg) : msg;
|
|
535
624
|
if (!frame) return refuse("a sealed frame did not open");
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
peerPort,
|
|
547
|
-
send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
|
|
548
|
-
});
|
|
625
|
+
// ONE QUESTION AT A TIME (#1739). Opened as it arrives — the seal counts
|
|
626
|
+
// frames in the order they came — and answered in that order, each after
|
|
627
|
+
// the last. A caller with more than a few waiting is not asking the way a
|
|
628
|
+
// round does; see MAX_WAITING_FRAMES.
|
|
629
|
+
if (answering) {
|
|
630
|
+
if (waiting.length >= MAX_WAITING_FRAMES) return refuse("too many questions at once");
|
|
631
|
+
waiting.push(frame);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
answer(frame);
|
|
549
635
|
}, refuse));
|
|
550
636
|
};
|
|
551
637
|
|
|
@@ -612,10 +698,14 @@ export function createSyncServer({
|
|
|
612
698
|
catch (err) { finish(reject, err); }
|
|
613
699
|
}),
|
|
614
700
|
port: () => server?.address()?.port ?? null,
|
|
701
|
+
/** Whether this listener made `challenge` for a connection still open —
|
|
702
|
+
* true when the deck at the other end of a dial is this one. */
|
|
703
|
+
issued: challenge => typeof challenge === "string" && challenges.has(challenge),
|
|
615
704
|
stop() {
|
|
616
705
|
cancelStart?.();
|
|
617
706
|
for (const s of live) s.destroy();
|
|
618
707
|
live.clear();
|
|
708
|
+
challenges.clear();
|
|
619
709
|
try { server?.close(); } catch { /* not listening */ }
|
|
620
710
|
server = null;
|
|
621
711
|
},
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -317,8 +317,9 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
|
|
|
317
317
|
* A reason rather than a boolean, because each answer asks for something
|
|
318
318
|
* different: a packet that is not a beacon, this deck's own echo, or another
|
|
319
319
|
* deck on this computer is nothing to act on; a deck wearing this one's key
|
|
320
|
-
* (`id-clash`) is a
|
|
321
|
-
*
|
|
320
|
+
* (`id-clash`) is a claim to check, and a key to replace if it holds (see
|
|
321
|
+
* idClash in lan-engine.mjs); a paired deck is a peer to note; and a stranger
|
|
322
|
+
* is a row somebody can accept.
|
|
322
323
|
*
|
|
323
324
|
* Self-recognition is by fingerprint, not by address: a deck hears its own
|
|
324
325
|
* broadcast on every interface it owns, and filtering by address would need a
|
|
@@ -390,6 +391,13 @@ export function trustedPeer(trusted, fp) {
|
|
|
390
391
|
* different deck wearing the name — and 48 bits of fingerprint is far past
|
|
391
392
|
* accident, so it is somebody trying. The old entry stands and the caller is
|
|
392
393
|
* told nothing changed.
|
|
394
|
+
*
|
|
395
|
+
* `auto` SAYS A SWITCH MADE THE PIN, not a person — the accept switch pressing
|
|
396
|
+
* accept for the owner. A pin that says so may take what this deck shares and
|
|
397
|
+
* may not place logins here: see roundWith in lan-engine.mjs. A person's pin
|
|
398
|
+
* of the same key takes the mark away, because pressing accept, or pairing by
|
|
399
|
+
* invite, is the choice the switch only stood in for; a switch never puts it
|
|
400
|
+
* back on a pin a person made.
|
|
393
401
|
*/
|
|
394
402
|
export function addTrusted(trusted, entry) {
|
|
395
403
|
const list = Array.isArray(trusted) ? trusted : [];
|
|
@@ -397,8 +405,13 @@ export function addTrusted(trusted, entry) {
|
|
|
397
405
|
const had = trustedPeer(list, entry.fp);
|
|
398
406
|
if (had) {
|
|
399
407
|
if (had.pub !== entry.pub) return { list, added: false };
|
|
408
|
+
const chosen = had.auto && entry.auto !== true;
|
|
400
409
|
return {
|
|
401
|
-
list: list.map(t =>
|
|
410
|
+
list: list.map(t => {
|
|
411
|
+
if (t.fp !== entry.fp) return t;
|
|
412
|
+
const { auto: _auto, ...kept } = t;
|
|
413
|
+
return { ...(chosen ? kept : t), name: entry.name ?? t.name };
|
|
414
|
+
}),
|
|
402
415
|
added: false,
|
|
403
416
|
};
|
|
404
417
|
}
|
|
@@ -406,7 +419,8 @@ export function addTrusted(trusted, entry) {
|
|
|
406
419
|
// was already trusted keeps whatever date it had, and one pinned before this
|
|
407
420
|
// field existed keeps having none rather than being given today's.
|
|
408
421
|
const at = Number.isFinite(entry.at) && entry.at > 0 ? { at: entry.at } : {};
|
|
409
|
-
|
|
422
|
+
const auto = entry.auto === true ? { auto: true } : {};
|
|
423
|
+
return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at, ...auto }], added: true };
|
|
410
424
|
}
|
|
411
425
|
|
|
412
426
|
/** Take one back out. Unpairing stops what has not happened yet and takes back
|
package/src/server/lifecycle.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
16
|
import { createActivity } from "./activity.mjs";
|
|
17
|
-
import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
|
|
17
|
+
import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep, turnsQuiet } from "./auto-update.mjs";
|
|
18
18
|
import { PRODUCT } from "./brand.mjs";
|
|
19
19
|
import { logWritableNow } from "./event-log.mjs";
|
|
20
20
|
import { readBody, send } from "./http-io.mjs";
|
|
@@ -165,6 +165,14 @@ async function handleUpgrade(_req, res) {
|
|
|
165
165
|
// `npx -y <spec>@latest` instead of re-running the files already here. Granted
|
|
166
166
|
// only where that is genuinely how this copy updates — the mode is decided from
|
|
167
167
|
// the install on disk, never from the request.
|
|
168
|
+
//
|
|
169
|
+
// `{ whenIdle: true }` is the page's automatic restart, which is granted only
|
|
170
|
+
// while no turn is running here (turnsQuiet). The page counts its idle stretch
|
|
171
|
+
// from its own graph, and a paused canvas stops applying events, so a turn sent
|
|
172
|
+
// from the terminal was invisible to it and the deck restarted under the agent
|
|
173
|
+
// (#1764). This process hears every turn whatever a page is doing, and it is
|
|
174
|
+
// asked at the moment the restart would happen. A person's press does not send
|
|
175
|
+
// it: "Restart anyway" is theirs to make.
|
|
168
176
|
async function handleRestart(req, res) {
|
|
169
177
|
if (!_onRestart) return send(res, 501, { ok: false, reason: "unsupervised" });
|
|
170
178
|
// Enforced here and not only in the UI. Under --no-persist a restart destroys
|
|
@@ -178,9 +186,14 @@ async function handleRestart(req, res) {
|
|
|
178
186
|
if (!canRestartNow()) return send(res, 409, { ok: false, reason: "log_unwritable" });
|
|
179
187
|
|
|
180
188
|
let wantUpgrade = false;
|
|
189
|
+
let whenIdle = false;
|
|
181
190
|
if (req.method === "POST") {
|
|
182
191
|
const body = await readBody(req, res).catch(() => null);
|
|
183
|
-
try {
|
|
192
|
+
try {
|
|
193
|
+
const ask = JSON.parse(body ?? "");
|
|
194
|
+
wantUpgrade = ask?.upgrade === true;
|
|
195
|
+
whenIdle = ask?.whenIdle === true;
|
|
196
|
+
} catch { /* a plain restart */ }
|
|
184
197
|
}
|
|
185
198
|
let mode = null;
|
|
186
199
|
if (wantUpgrade) {
|
|
@@ -194,6 +207,12 @@ async function handleRestart(req, res) {
|
|
|
194
207
|
}
|
|
195
208
|
|
|
196
209
|
if (_restarting) return send(res, 202, { ok: true, already: true });
|
|
210
|
+
if (whenIdle) {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
if (!turnsQuiet({ busy: activity.busy(now), quietMs: activity.quietMs(now) })) {
|
|
213
|
+
return send(res, 409, { ok: false, reason: "busy" });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
197
216
|
_restarting = true;
|
|
198
217
|
// Accepted either way — the ask is good and the launcher holds on to it — but
|
|
199
218
|
// the two are not the same event and answering 200 to both would be this
|
|
@@ -19,7 +19,8 @@ function currentLiveVideo(html) {
|
|
|
19
19
|
|
|
20
20
|
export async function fetchLiveRadioMix({ fetchImpl } = {}) {
|
|
21
21
|
const now = Date.now();
|
|
22
|
-
|
|
22
|
+
// The stamp, not the value: a miss is remembered too, as null.
|
|
23
|
+
if (cacheAt && now - cacheAt < cacheTtl) return cache;
|
|
23
24
|
if (inflight) return inflight;
|
|
24
25
|
const get = fetchImpl ?? fetch;
|
|
25
26
|
inflight = (async () => {
|
|
@@ -11,7 +11,7 @@ import { existsSync } from "node:fs";
|
|
|
11
11
|
import { PRODUCT } from "./brand.mjs";
|
|
12
12
|
import { codexCwdInWorkspace } from "./log-election.mjs";
|
|
13
13
|
import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
|
|
14
|
-
import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, payloadChars } from "./ring-bounds.mjs";
|
|
14
|
+
import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, payloadChars } from "./ring-bounds.mjs";
|
|
15
15
|
// event-pipeline.mjs's pushEvent, reached without importing it — see
|
|
16
16
|
// event-sink.mjs.
|
|
17
17
|
import { pushEvent } from "./event-sink.mjs";
|
|
@@ -151,28 +151,56 @@ export function replayScope(workspace, platform = process.platform, providers =
|
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The ring's three bounds, counted the way the ring counts them, for a reader
|
|
156
|
+
* that stages the log newest-first and has to know when to stop.
|
|
157
|
+
*
|
|
158
|
+
* MAX_BUFFER is HOOK events since #1032: the enrichment interleaved among them
|
|
159
|
+
* (LAST_VALUE_WINS) rides free, bounded only by MAX_RING_ENTRIES and
|
|
160
|
+
* MAX_BUFFER_CHARS. This used to count every line toward MAX_BUFFER, and at the
|
|
161
|
+
* ~1.6 enrichment lines a real deck logs per hook event that stopped the boot
|
|
162
|
+
* replay with about a third of the ring's window rebuilt (#1750). `add` stages
|
|
163
|
+
* one payload and answers whether the ring is now full.
|
|
164
|
+
*/
|
|
165
|
+
function ringBudget(maxEvents, maxEntries, maxChars) {
|
|
166
|
+
let hookEvents = 0;
|
|
167
|
+
let entries = 0;
|
|
168
|
+
let chars = 0;
|
|
169
|
+
const full = () => hookEvents >= maxEvents || entries >= maxEntries || chars >= maxChars;
|
|
170
|
+
return {
|
|
171
|
+
full,
|
|
172
|
+
add(payload) {
|
|
173
|
+
entries++;
|
|
174
|
+
if (!isEnrichment(payload)) hookEvents++;
|
|
175
|
+
chars += ENVELOPE_CHARS + payloadChars(payload);
|
|
176
|
+
return full();
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
154
181
|
/**
|
|
155
182
|
* Can this log fill the ring on its own?
|
|
156
183
|
*
|
|
157
184
|
* Read from the END and bounded by the ring, so the answer costs at most one
|
|
158
185
|
* ring's worth of parsing however large the file is — and on a full log it
|
|
159
|
-
* stops within the first few
|
|
186
|
+
* stops within the first few thousand lines. Deliberately UNSCOPED: the number
|
|
160
187
|
* it returns is an upper bound on what any workspace predicate will admit, so
|
|
161
188
|
* `false` is a certainty that the archive is needed while `true` is only the
|
|
162
189
|
* absence of evidence that it is. See the order-dependent branch of replayLog
|
|
163
190
|
* for why that asymmetry is the right way round.
|
|
191
|
+
*
|
|
192
|
+
* Counted by `ringBudget`, the same rule the unscoped replay stops by, so
|
|
193
|
+
* "fills the ring" means MAX_BUFFER hook events and not MAX_BUFFER lines of
|
|
194
|
+
* which most are enrichment (#1750).
|
|
164
195
|
*/
|
|
165
|
-
async function fillsRing(filePath, maxEvents, maxChars) {
|
|
166
|
-
|
|
167
|
-
let chars = 0;
|
|
196
|
+
async function fillsRing(filePath, maxEvents, maxEntries, maxChars) {
|
|
197
|
+
const budget = ringBudget(maxEvents, maxEntries, maxChars);
|
|
168
198
|
for await (const line of linesFromEnd(filePath)) {
|
|
169
199
|
if (!line) continue;
|
|
170
200
|
let evt;
|
|
171
201
|
try { evt = JSON.parse(line); } catch { continue; }
|
|
172
202
|
if (!evt || typeof evt !== "object" || !evt.payload) continue;
|
|
173
|
-
|
|
174
|
-
chars += ENVELOPE_CHARS + payloadChars(evt.payload);
|
|
175
|
-
if (n >= maxEvents || chars >= maxChars) return true;
|
|
203
|
+
if (budget.add(evt.payload)) return true;
|
|
176
204
|
}
|
|
177
205
|
return false;
|
|
178
206
|
}
|
|
@@ -225,11 +253,15 @@ async function fillsRing(filePath, maxEvents, maxChars) {
|
|
|
225
253
|
* feeding the eviction loop, on the critical path of a boot, every time.
|
|
226
254
|
*
|
|
227
255
|
* So the lines arrive newest-first and the loop stops the moment the ring is
|
|
228
|
-
* full, which makes the cost a property of
|
|
229
|
-
* the user has been running the deck. Nothing is lost by it: what a
|
|
230
|
-
* replay left in the ring was always the NEWEST admitted events that
|
|
231
|
-
* that is
|
|
232
|
-
* the ring
|
|
256
|
+
* full, which makes the cost a property of the ring's bounds rather than of how
|
|
257
|
+
* long the user has been running the deck. Nothing is lost by it: what a
|
|
258
|
+
* forward replay left in the ring was always the NEWEST admitted events that
|
|
259
|
+
* fit, and that is the set this collects — provided "full" is counted the way
|
|
260
|
+
* the ring counts it. Since #1032 that is MAX_BUFFER hook events with their
|
|
261
|
+
* enrichment riding along, and counting every line toward MAX_BUFFER instead
|
|
262
|
+
* rebuilt about a third of the window (#1750); see ringBudget. A young log —
|
|
263
|
+
* too few events to fill the ring — is read to its start, and costs what it
|
|
264
|
+
* always did.
|
|
233
265
|
*
|
|
234
266
|
* The order of the pushes is still oldest-first. `seq` is assigned by pushEvent
|
|
235
267
|
* in the order it is called, and a ring numbered backwards would hand every
|
|
@@ -245,12 +277,12 @@ async function fillsRing(filePath, maxEvents, maxChars) {
|
|
|
245
277
|
* that case cheap needs an index of where a workspace's lines are, which is a
|
|
246
278
|
* different change from this one.
|
|
247
279
|
*/
|
|
248
|
-
/* Exported for the suite, with
|
|
280
|
+
/* Exported for the suite, with the ceilings as parameters. The byte budget is
|
|
249
281
|
* 128 MiB, so a test that wanted to reach it honestly would have to write 128
|
|
250
282
|
* MiB — which is why the count bound was the only one anything pinned, and why
|
|
251
|
-
* the byte bound was the one that broke. Production passes
|
|
283
|
+
* the byte bound was the one that broke. Production passes none of them. */
|
|
252
284
|
export async function replayLog(filePath, workspace = "", {
|
|
253
|
-
maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS, providers = null,
|
|
285
|
+
maxEvents = MAX_BUFFER, maxEntries = MAX_RING_ENTRIES, maxChars = MAX_BUFFER_CHARS, providers = null,
|
|
254
286
|
} = {}) {
|
|
255
287
|
// THE ARCHIVE IS PART OF THE HISTORY, and for two years nothing read it.
|
|
256
288
|
// `maybeRotatePersistFile` renames events.jsonl to events.jsonl.1 at 50 MB;
|
|
@@ -311,7 +343,7 @@ export async function replayLog(filePath, workspace = "", {
|
|
|
311
343
|
// what it does today. Nothing regresses; the case a rotation creates, where
|
|
312
344
|
// the live log holds a handful of lines, is the one that is fixed.
|
|
313
345
|
const files = [];
|
|
314
|
-
if (archiveThere && !(liveThere && await fillsRing(filePath, maxEvents, maxChars))) files.push(archivePath);
|
|
346
|
+
if (archiveThere && !(liveThere && await fillsRing(filePath, maxEvents, maxEntries, maxChars))) files.push(archivePath);
|
|
315
347
|
if (liveThere) files.push(filePath);
|
|
316
348
|
for (const file of files) {
|
|
317
349
|
for await (const line of linesFromStart(file)) {
|
|
@@ -324,8 +356,8 @@ export async function replayLog(filePath, workspace = "", {
|
|
|
324
356
|
}
|
|
325
357
|
} else {
|
|
326
358
|
// Newest first, so this is filled back to front and then walked in reverse
|
|
327
|
-
// to push. Bounded by
|
|
328
|
-
// memory here a property of the ring rather than of the file.
|
|
359
|
+
// to push. Bounded by ALL of the ring's limits (see ringBudget), which is
|
|
360
|
+
// what makes the memory here a property of the ring rather than of the file.
|
|
329
361
|
//
|
|
330
362
|
// The count alone was not enough, and the comment that used to say it was
|
|
331
363
|
// predates #625. Eviction is the only thing that applies MAX_BUFFER_CHARS,
|
|
@@ -339,24 +371,23 @@ export async function replayLog(filePath, workspace = "", {
|
|
|
339
371
|
// best-effort and its failure is only logged, and the rotation's own note
|
|
340
372
|
// in event-log.mjs records logs reaching gigabytes.
|
|
341
373
|
const newestFirst = [];
|
|
342
|
-
|
|
374
|
+
const budget = ringBudget(maxEvents, maxEntries, maxChars);
|
|
343
375
|
// Newest generation first. The second pass runs only if the first stopped
|
|
344
376
|
// because it ran out of FILE rather than because it reached a bound, which
|
|
345
377
|
// is the "cannot fill the ring" test stated exactly and for free — and it
|
|
346
378
|
// is also why a full live log never opens the archive at all.
|
|
347
379
|
for (const file of liveThere ? [filePath, archivePath] : [archivePath]) {
|
|
348
380
|
if (!existsSync(file)) continue;
|
|
349
|
-
if (
|
|
381
|
+
if (budget.full()) break;
|
|
350
382
|
for await (const line of linesFromEnd(file)) {
|
|
351
383
|
if (!line) continue;
|
|
352
384
|
const evt = parse(line);
|
|
353
385
|
if (!usable(evt) || !admits(evt.payload)) continue;
|
|
354
386
|
newestFirst.push(evt);
|
|
355
|
-
stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
|
|
356
387
|
// Everything older than this would be evicted by the events already held,
|
|
357
388
|
// so reading further is work whose only result is throwing it away.
|
|
358
|
-
//
|
|
359
|
-
if (
|
|
389
|
+
// Any of the three limits reaching its ceiling means exactly that.
|
|
390
|
+
if (budget.add(evt.payload)) break;
|
|
360
391
|
}
|
|
361
392
|
}
|
|
362
393
|
for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
|
|
@@ -359,6 +359,30 @@ export function scopeEnv(env = process.env) {
|
|
|
359
359
|
);
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* The PATH for the login item's job (#1777).
|
|
364
|
+
*
|
|
365
|
+
* The service manager's, not the shell's, is what a login item gets: launchd's
|
|
366
|
+
* is /usr/bin:/bin:/usr/sbin:/sbin, and the systemd user manager's names no
|
|
367
|
+
* nvm, fnm or Volta directory. A deck started there could not find the npm its
|
|
368
|
+
* own update runs, or the node a `#!/usr/bin/env node` claude asks for — on
|
|
369
|
+
* exactly the installs where the prefix is the user's own, so the update is
|
|
370
|
+
* offered and then fails with `spawn npm ENOENT` on every retry.
|
|
371
|
+
*
|
|
372
|
+
* Node's own directory first, since npm, npx and a global claude sit beside it
|
|
373
|
+
* on every layout that installs them together; then the installing shell's
|
|
374
|
+
* PATH in its own order, each directory once. Empty entries are dropped: a
|
|
375
|
+
* shell reads one as the current directory, and a login job has no business
|
|
376
|
+
* resolving programs out of wherever it happened to start.
|
|
377
|
+
*/
|
|
378
|
+
export function jobPath({ execPath, path = "", platform = process.platform } = {}) {
|
|
379
|
+
const sep = platform === "win32" ? ";" : ":";
|
|
380
|
+
const exe = String(execPath ?? "");
|
|
381
|
+
const node = exe ? (platform === "win32" ? winPath : posixPath).dirname(exe) : "";
|
|
382
|
+
const dirs = [node === "." ? "" : node, ...String(path ?? "").split(sep)].filter(d => d !== "");
|
|
383
|
+
return [...new Set(dirs)].join(sep);
|
|
384
|
+
}
|
|
385
|
+
|
|
362
386
|
/**
|
|
363
387
|
* Write the login item and register it.
|
|
364
388
|
*
|
|
@@ -373,7 +397,13 @@ export function installService({
|
|
|
373
397
|
execPath = process.execPath,
|
|
374
398
|
script,
|
|
375
399
|
logPath,
|
|
376
|
-
|
|
400
|
+
// `--at-login` because registering the job also STARTS it, on Linux and
|
|
401
|
+
// macOS — `enable --now`, and RunAtLoad under `launchctl load` — and a deck
|
|
402
|
+
// of the default shape that finds a `--workspace` or `--no-persist` deck
|
|
403
|
+
// running replaces it: the newest start wins. So the first start's own offer
|
|
404
|
+
// stopped the deck the user had just launched (#1778). The marker is what
|
|
405
|
+
// lets secondStart tell a start nobody typed from one somebody did.
|
|
406
|
+
args = ["--no-open", "--at-login"],
|
|
377
407
|
// Extra variables for the JOB's environment, which is not this process's.
|
|
378
408
|
//
|
|
379
409
|
// A login item runs in the session the service manager builds, not in the
|
|
@@ -422,9 +452,14 @@ export function installService({
|
|
|
422
452
|
return { ok: true, path: `Task Scheduler ${GLYPH_ARROW} ${SERVICE_LABEL}`, how: "schtasks" };
|
|
423
453
|
}
|
|
424
454
|
|
|
455
|
+
// PATH beside the scope directories rather than among them: it is not
|
|
456
|
+
// which deck this is but whether the deck can find its own tools, so it is
|
|
457
|
+
// in every job — and a caller's own serviceEnv can still name one.
|
|
458
|
+
const PATH = jobPath({ execPath, path: env?.PATH, platform });
|
|
459
|
+
const jobEnv = { ...(PATH ? { PATH } : {}), ...serviceEnv };
|
|
425
460
|
const body = platform === "darwin"
|
|
426
|
-
? plistFor({ execPath, script, logPath, args, env:
|
|
427
|
-
: unitFor({ execPath, script, logPath, args, env:
|
|
461
|
+
? plistFor({ execPath, script, logPath, args, env: jobEnv })
|
|
462
|
+
: unitFor({ execPath, script, logPath, args, env: jobEnv, product });
|
|
428
463
|
fs.mkdirSync(dirOf(path), { recursive: true });
|
|
429
464
|
fs.writeFileSync(path, body, { mode: 0o644 });
|
|
430
465
|
const cmd = registerCommand(platform, path);
|
|
@@ -66,14 +66,16 @@ const C_LOCALE = { LC_ALL: "", LC_NUMERIC: "C" };
|
|
|
66
66
|
|
|
67
67
|
/** Run a command and resolve its stdout, or null. Never rejects, never inherits
|
|
68
68
|
* a shell, never inherits a locale, and is killed rather than allowed to hang
|
|
69
|
-
* the sampler.
|
|
70
|
-
|
|
69
|
+
* the sampler. `env` is what the child starts from — the deck's own, unless a
|
|
70
|
+
* command needs more than that, as Tailscale's CLI does (cliEnv in
|
|
71
|
+
* tailscale.mjs) — and the forced number format goes over it either way. */
|
|
72
|
+
export function run(file, args, timeoutMs = 2_000, env = process.env) {
|
|
71
73
|
return new Promise(resolve => {
|
|
72
74
|
let child;
|
|
73
75
|
try {
|
|
74
76
|
child = spawn(file, args, {
|
|
75
77
|
windowsHide: true,
|
|
76
|
-
env: { ...
|
|
78
|
+
env: { ...env, ...C_LOCALE },
|
|
77
79
|
// stderr is PIPED AND NEVER READ, which is a deadlock waiting for a
|
|
78
80
|
// chatty child: a pipe nobody drains fills at 64 KB and the writer
|
|
79
81
|
// blocks there until this function's own deadline kills it. Nothing
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
classifyRoute, parseNetstatE, parseNetstatIbn, parseProcNetDev, rateBetween,
|
|
19
19
|
routeIfaceDarwin, routeIfaceLinux, routeLabel, tailscaleExitFromStatus,
|
|
20
20
|
} from "./network-metrics.mjs";
|
|
21
|
+
import { cliEnv, tailscaleCandidates } from "./tailscale.mjs";
|
|
21
22
|
|
|
22
23
|
// ── the network ────────────────────────────────────────────────────────────
|
|
23
24
|
//
|
|
@@ -136,6 +137,16 @@ function connectMs(address, port = API_PORT, timeoutMs = CONNECT_TIMEOUT_MS) {
|
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/** `tailscale status --json` as an object, or null for anything that is not
|
|
141
|
+
* one: nothing on stdout, the macOS GUI's message, a version warning. */
|
|
142
|
+
function statusDocument(out) {
|
|
143
|
+
if (typeof out !== "string") return null;
|
|
144
|
+
try {
|
|
145
|
+
const doc = JSON.parse(out);
|
|
146
|
+
return doc && typeof doc === "object" ? doc : null;
|
|
147
|
+
} catch { return null; }
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
/** Which way traffic to `address` leaves, and — for a Tailscale exit node —
|
|
140
151
|
* through which machine and whether through a relay. */
|
|
141
152
|
async function readRoute(address, platform, runner) {
|
|
@@ -147,8 +158,20 @@ async function readRoute(address, platform, runner) {
|
|
|
147
158
|
// Tailscale on a Mac is a utun like every other VPN there, so the question is
|
|
148
159
|
// asked of any tunnel rather than only of one named tailscale0. A machine
|
|
149
160
|
// without the CLI answers null and keeps the generic label.
|
|
150
|
-
|
|
151
|
-
|
|
161
|
+
//
|
|
162
|
+
// WHERE THE CLI IS AND HOW IT IS RUN are tailscale.mjs's answers, not PATH's
|
|
163
|
+
// (#1772). This asked a bare `tailscale`, and the Mac app keeps its CLI in
|
|
164
|
+
// the bundle and puts nothing on PATH unless its owner pressed "Install CLI"
|
|
165
|
+
// — and a deck started at login has launchd's PATH anyway. The bundle binary
|
|
166
|
+
// is also the GUI, which without cliEnv() prints a message and exits 0. So
|
|
167
|
+
// the candidates are tried in the LAN code's order, as a CLI, and the first
|
|
168
|
+
// one that answers with a status document is the one that is installed.
|
|
169
|
+
for (const file of tailscaleCandidates(platform)) {
|
|
170
|
+
const doc = statusDocument(await runner(file, ["status", "--json"], 3_000, cliEnv()));
|
|
171
|
+
if (!doc) continue;
|
|
172
|
+
const exit = tailscaleExitFromStatus(doc);
|
|
173
|
+
return exit ? { kind: "tailscale-exit", iface, ...exit } : kind;
|
|
174
|
+
}
|
|
152
175
|
return kind;
|
|
153
176
|
}
|
|
154
177
|
|
|
@@ -14,6 +14,7 @@ import { accessSync, constants as FS, unlinkSync, writeFileSync } from "node:fs"
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import { join, resolve } from "node:path";
|
|
16
16
|
import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
17
|
+
import { npmCliLaunch } from "./npx.mjs";
|
|
17
18
|
import {
|
|
18
19
|
frozenNameInstall, hostPackage, installedName, isGitCheckout, isNpxInstall,
|
|
19
20
|
PUBLISHED_NAME, successorRoot, upgradeCommand, upgradeName,
|
|
@@ -61,7 +62,13 @@ const INSTALL_TIMEOUT_MS = 300_000; // a cold global install on a slow line
|
|
|
61
62
|
*/
|
|
62
63
|
export function upgradeSpec(target, platform = process.platform, deps) {
|
|
63
64
|
const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
|
|
64
|
-
|
|
65
|
+
// POSIX: the npm-cli.js beside this node, and the bare name only when there
|
|
66
|
+
// is none, because a deck started at login has no PATH that finds npm on a
|
|
67
|
+
// Homebrew or nvm install (#1777). See npmCliLaunch.
|
|
68
|
+
if (platform !== "win32") {
|
|
69
|
+
return { ...(npmCliLaunch(args, { platform, ...deps }) ?? spawnSpec("npm", args, platform)), plain: args };
|
|
70
|
+
}
|
|
71
|
+
const file = shimPath("npm.cmd", deps) ?? "npm.cmd";
|
|
65
72
|
return { ...spawnSpec(file, args, platform), plain: args };
|
|
66
73
|
}
|
|
67
74
|
|