ccdeck 3.32.1 → 3.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +14 -12
  2. package/bin/agent-dag.js +6 -0
  3. package/bin/cli/help.js +2 -0
  4. package/bin/cli/one-shot.js +32 -13
  5. package/bin/cli/second-start.js +1 -0
  6. package/bin/cli/startup.js +34 -0
  7. package/bin/cli/uninstall.js +47 -2
  8. package/bin/deck.js +12 -1
  9. package/dist/web/assets/BrowserWatchModal-B3UjvY82.js +1 -0
  10. package/dist/web/assets/UsageHistoryModal-DxNI8gR6.js +1 -0
  11. package/dist/web/assets/index-BIANlQ9A.css +1 -0
  12. package/dist/web/assets/index-OLDoLpOW.js +299 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +40 -0
  16. package/src/server/account-projects.mjs +11 -2
  17. package/src/server/args.mjs +12 -2
  18. package/src/server/auto-update.mjs +14 -1
  19. package/src/server/best-of-nostalgia.mjs +2 -1
  20. package/src/server/block-notify.mjs +114 -11
  21. package/src/server/browser-history.mjs +81 -4
  22. package/src/server/browser-presence.mjs +98 -2
  23. package/src/server/browser-react.mjs +39 -4
  24. package/src/server/browser-watch-seen.mjs +35 -1
  25. package/src/server/browser-watch.mjs +105 -18
  26. package/src/server/cafe-music-bgm.mjs +2 -1
  27. package/src/server/ccusage-install.mjs +8 -4
  28. package/src/server/ccusage.mjs +3 -1
  29. package/src/server/claude-accounts.mjs +38 -16
  30. package/src/server/codex-dir.mjs +8 -6
  31. package/src/server/codex-quota.mjs +4 -2
  32. package/src/server/codex-usage.mjs +93 -38
  33. package/src/server/codex-watch.mjs +150 -18
  34. package/src/server/cswap-auto-loop.mjs +15 -2
  35. package/src/server/cswap-auto-readers.mjs +10 -4
  36. package/src/server/cswap-auto.mjs +6 -6
  37. package/src/server/cswap-install.mjs +118 -3
  38. package/src/server/deck-prefs.mjs +4 -0
  39. package/src/server/event-pipeline.mjs +3 -0
  40. package/src/server/event-ring.mjs +2 -2
  41. package/src/server/good-life-radio.mjs +2 -1
  42. package/src/server/index.mjs +3 -2
  43. package/src/server/installer.mjs +62 -12
  44. package/src/server/lan-beacon.mjs +6 -4
  45. package/src/server/lan-call.mjs +4 -0
  46. package/src/server/lan-dials.mjs +10 -1
  47. package/src/server/lan-engine.mjs +221 -44
  48. package/src/server/lan-requests.mjs +88 -5
  49. package/src/server/lan-round-record.mjs +21 -4
  50. package/src/server/lan-socket.mjs +105 -15
  51. package/src/server/lan-sync.mjs +18 -4
  52. package/src/server/lifecycle.mjs +21 -2
  53. package/src/server/live-radio-mix.mjs +2 -1
  54. package/src/server/log-replay.mjs +55 -24
  55. package/src/server/login-service.mjs +38 -3
  56. package/src/server/metrics-run.mjs +5 -3
  57. package/src/server/network-sampler.mjs +25 -2
  58. package/src/server/npm-upgrade.mjs +8 -1
  59. package/src/server/npx.mjs +32 -5
  60. package/src/server/process-list.mjs +103 -13
  61. package/src/server/purge-key.mjs +52 -24
  62. package/src/server/request-gates.mjs +47 -14
  63. package/src/server/retire-sound-hook.mjs +56 -9
  64. package/src/server/ring-bounds.mjs +10 -0
  65. package/src/server/running-deck.mjs +72 -2
  66. package/src/server/session-enrichment.mjs +40 -8
  67. package/src/server/stop-deck.mjs +13 -4
  68. package/src/server/system-metrics.mjs +2 -2
  69. package/src/server/transcript-scan.mjs +27 -6
  70. package/dist/web/assets/BrowserWatchModal-ohP7oQCN.js +0 -1
  71. package/dist/web/assets/UsageHistoryModal-CNjMaM59.js +0 -1
  72. package/dist/web/assets/index-DKn7nc15.css +0 -1
  73. package/dist/web/assets/index-P2zWn3Hw.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
- const done = () => { clearTimeout(deadline); live.delete(sock); };
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
- handlers?.(frame, {
537
- sock, peerFp, key, sealed: !!chan,
538
- // Where this deck dials the caller BACK. A deck that only ever calls in
539
- // is one this deck holds no address for, so it could receive nothing —
540
- // accounts move only toward the deck that dials (see roundWith). The
541
- // address the caller connected from, and the port it said it listens
542
- // on, are a dialable pair the engine can add so the next round reaches
543
- // it. `peerAddr` is the source of this very connection; `peerPort` came
544
- // from the hello, not the ephemeral source port.
545
- peerAddr: from(sock),
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
  },
@@ -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 key to replace; a paired deck is a peer to note; and a
321
- * stranger is a row somebody can accept.
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 => (t.fp === entry.fp ? { ...t, name: entry.name ?? t.name } : 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
- return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at }], added: true };
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
@@ -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 { wantUpgrade = JSON.parse(body ?? "")?.upgrade === true; } catch { /* a plain restart */ }
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
- if (cache && now - cacheAt < cacheTtl) return cache;
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 hundred lines. Deliberately UNSCOPED: the number
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
- let n = 0;
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
- n++;
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 MAX_BUFFER rather than of how long
229
- * the user has been running the deck. Nothing is lost by it: what a forward
230
- * replay left in the ring was always the NEWEST admitted events that fit, and
231
- * that is exactly the set this collects. A young log — too few events to fill
232
- * the ring — is read to its start, and costs what it always did.
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 both ceilings as parameters. The byte budget is
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 neither argument. */
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 BOTH of the ring's limits, which is what makes the
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
- let stagedChars = 0;
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 (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
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
- // Either limit reaching its ceiling means exactly that.
359
- if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
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
- args = ["--no-open"],
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: serviceEnv })
427
- : unitFor({ execPath, script, logPath, args, env: serviceEnv, product });
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
- export function run(file, args, timeoutMs = 2_000) {
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: { ...process.env, ...C_LOCALE },
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
- const exit = tailscaleExitFromStatus(await runner("tailscale", ["status", "--json"], 3_000));
151
- if (exit) return { kind: "tailscale-exit", iface, ...exit };
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
- const file = platform === "win32" ? (shimPath("npm.cmd", deps) ?? "npm.cmd") : "npm";
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