agent-dag 3.2.0 → 3.3.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.
@@ -27,14 +27,14 @@
27
27
  // is running, so a deck that was closed all weekend still answers Monday's
28
28
  // question completely — which is why there is no process to keep alive and no
29
29
  // gap to apologise for.
30
- import { readFileSync, statSync } from "node:fs";
30
+ import { statSync } from "node:fs";
31
31
  import { readdir, readFile } from "node:fs/promises";
32
32
  import { join } from "node:path";
33
33
  import { claudeConfigDir } from "./claude-dir.mjs";
34
34
  import { discoverProfiles } from "./browser-profiles.mjs";
35
- import { readVisitsSince } from "./browser-history.mjs";
35
+ import { msToChromeTime, readVisitsSince } from "./browser-history.mjs";
36
36
  import { classify, toEpisodes, defaultExclusions, isProgramNavigation } from "./agent-activity.mjs";
37
- import { appendLog, logPath, mergeEpisodes, readStore, undismissed, writeStore } from "./browser-watch-store.mjs";
37
+ import { appendLog, logPath, mergeEpisodes, readStore, undismissed, updateStore, writeStore } from "./browser-watch-store.mjs";
38
38
  import { browserSurvey } from "./browser-presence.mjs";
39
39
  import { available, performable, react } from "./browser-react.mjs";
40
40
  import { RELAY_HOST } from "./relay-guard.mjs";
@@ -86,6 +86,22 @@ function mtimeMs(file, deps) {
86
86
  * schedule. `stale` is reported so the panel can say when it last actually
87
87
  * looked rather than implying the answer is a live one.
88
88
  */
89
+ /**
90
+ * Is that pid still running?
91
+ *
92
+ * A bare `catch { continue; }` used to stand where this is called, which threw
93
+ * away the one distinction that matters: a process this account may not signal
94
+ * answers EPERM on POSIX and EACCES on Windows (libuv maps
95
+ * ERROR_ACCESS_DENIED), and both mean ALIVE. Treating them as gone made an
96
+ * elevated deck invisible to the writer election below, which is how a machine
97
+ * ends up with two elected writers — duplicate log lines, duplicate reactions,
98
+ * and two writers racing the same rename.
99
+ */
100
+ function pidAlive(pid) {
101
+ try { process.kill(pid, 0); return true; }
102
+ catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
103
+ }
104
+
89
105
  async function visitsFor(profile, { sinceChromeTime, copyDir, deps = {} }) {
90
106
  const stamp = mtimeMs(profile.historyPath, deps);
91
107
  if (stamp === null) return { rows: [], degraded: true, reason: "no-history-file", stamp: null };
@@ -227,7 +243,7 @@ export async function registeredDeckPorts(deps = {}) {
227
243
  try {
228
244
  const d = JSON.parse(await readFile(join(dir, f), "utf8"));
229
245
  if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
230
- try { process.kill(d.pid, 0); } catch { continue; }
246
+ if (!pidAlive(d.pid)) continue;
231
247
  ports.push(d.port);
232
248
  } catch { /* corrupt, or gone between listing and read */ }
233
249
  }
@@ -283,7 +299,7 @@ function note(level, text, atMs = Date.now(), parts = null) {
283
299
  if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
284
300
  }
285
301
 
286
- export function watchLog() {
302
+ function watchLog() {
287
303
  return logLines.slice();
288
304
  }
289
305
 
@@ -341,7 +357,7 @@ async function isReactingDeck(deps = {}) {
341
357
  // than by a version comparison this would otherwise have to keep.
342
358
  if (d.watch !== true) continue;
343
359
  // A record whose process is gone is a leftover, not a rival.
344
- try { process.kill(d.pid, 0); } catch { continue; }
360
+ if (!pidAlive(d.pid)) continue;
345
361
  if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
346
362
  } catch { /* corrupt, or gone between listing and read */ }
347
363
  }
@@ -438,6 +454,24 @@ export async function browserWatchSnapshot({
438
454
  now = Date.now(),
439
455
  platform = process.platform,
440
456
  env = process.env,
457
+ // WHETHER TO READ THE BROWSERS AT ALL ON THIS CALL.
458
+ //
459
+ // The panel's badge polls this route every five minutes from the moment the
460
+ // page loads, and the read is not free or invisible: it copies each Chromium
461
+ // profile's whole History database into a temp file, queries it, and deletes
462
+ // the copy. That happened whether or not the watch was switched on, because
463
+ // `enabled` gates only what is KEPT and what is REACTED to.
464
+ //
465
+ // A switch that says off while the deck goes on copying the user's browsing
466
+ // history every five minutes is not a switch. So the background poll asks for
467
+ // `live=0`, and with the watch off that is honoured: the archive answers, and
468
+ // nothing touches a browser. Two things still read live — a watch that is ON,
469
+ // because recording in the background is the whole feature, and the panel
470
+ // itself, because that is the user looking.
471
+ //
472
+ // Named for what it does rather than `live`, which is already the local name
473
+ // for the episodes this poll built.
474
+ readBrowsers = true,
441
475
  deps = {},
442
476
  } = {}) {
443
477
  // The store answers first and the caller's arguments override it, so the
@@ -458,6 +492,36 @@ export async function browserWatchSnapshot({
458
492
  if (quietMs === undefined) quietMs = minutes(store.settings.quietMinutes);
459
493
  if (gapMs === undefined) gapMs = minutes(store.settings.gapMinutes);
460
494
 
495
+ // The archive alone, for a poll that has not asked to look and a watch that
496
+ // is not recording. Everything below this line reads browsers.
497
+ if (!readBrowsers && !enabled) {
498
+ const archived = undismissed(store.episodes, store.dismissed);
499
+ return {
500
+ ok: true,
501
+ settings: store.settings,
502
+ reactions: available(platform),
503
+ log: watchLog(),
504
+ profiles: [],
505
+ browsers: [],
506
+ episodes: archived,
507
+ coverage: {
508
+ startedMs: STARTED_MS,
509
+ oldestVisitMs: null,
510
+ lastHumanMs: null,
511
+ quietMs: quietMs ?? 15 * 60_000,
512
+ logPath: logPath(),
513
+ checkedMs: _checkedMs,
514
+ checks: _checks,
515
+ archived: archived.length,
516
+ now,
517
+ // Said rather than implied: a reader who wonders why the profile list
518
+ // is empty gets the reason, in the same word the switch uses.
519
+ why: "the watch is off, so no browser was read on this poll",
520
+ },
521
+ degraded: false,
522
+ };
523
+ }
524
+
461
525
  const profiles = (deps.discoverProfiles ?? discoverProfiles)(platform, env, undefined, deps.fs);
462
526
  // Fixed for the life of the process, which is also what keeps the mtime cache
463
527
  // working: a floor computed from `now` moves every millisecond and would land
@@ -465,9 +529,11 @@ export async function browserWatchSnapshot({
465
529
  // it re-read and re-copied every database on every request while looking
466
530
  // perfectly correct, because only the cost was wrong.
467
531
  const sinceMs = STARTED_MS;
468
- // Chrome counts microseconds from 1601. Built here rather than imported so the
469
- // window is one expression the reader can check against the reader's own.
470
- const sinceChromeTime = String((BigInt(sinceMs) + 11644473600000n) * 1000n);
532
+ // Chrome counts microseconds from 1601, and `msToChromeTime` is where that
533
+ // conversion lives its own doc names this caller. The inline copy that used
534
+ // to stand here duplicated both constants and, unlike the helper, had no
535
+ // guard for a non-finite input: it threw where the helper returns "0".
536
+ const sinceChromeTime = msToChromeTime(sinceMs);
471
537
 
472
538
  const exclude = defaultExclusions(deckOrigins);
473
539
  const opts = {};
@@ -632,7 +698,21 @@ export async function browserWatchSnapshot({
632
698
  if (fresh.length > 0) {
633
699
  note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
634
700
  }
635
- await (deps.writeStore ?? writeStore)({ settings: store.settings, episodes: kept, dismissed: store.dismissed }, undefined, deps);
701
+ // THE ARCHIVE IS OURS TO WRITE; THE OTHER TWO FIELDS ARE NOT. This poll
702
+ // takes about 400ms — a 21 MB History copy plus the sqlite read — and it
703
+ // used to write back the `dismissed` and `settings` it had read at the
704
+ // start, so a dismissal or a watch-off toggle made while it ran was
705
+ // reverted ten seconds later by the next poll. Re-merging inside the update
706
+ // keeps this function's own answer and takes the other two from disk as
707
+ // they are at the moment of the write.
708
+ const merge = cur => ({
709
+ settings: cur.settings,
710
+ episodes: mergeEpisodes(cur.episodes, live, now),
711
+ dismissed: cur.dismissed,
712
+ });
713
+ if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
714
+ else if (deps.writeStore) await deps.writeStore(merge(store), undefined, deps);
715
+ else await updateStore(merge, undefined, deps);
636
716
  await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
637
717
 
638
718
  // REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
@@ -823,6 +823,34 @@ let _byAgentUnsupported = false;
823
823
  */
824
824
  const blamesByAgent = (text) => /by-agent/i.test(String(text ?? ""));
825
825
 
826
+ /**
827
+ * BOTH REPORTS FROM ONE LOAD.
828
+ *
829
+ * `daily` answers "what did this range cost" and `session` answers "which
830
+ * session spent it". They used to be two commands and therefore two children —
831
+ * two npx resolutions, two Node starts, and two full walks of every transcript
832
+ * on the machine for the same set of files.
833
+ *
834
+ * `--sections` is ccusage's own answer to that: one load, several report
835
+ * sections in one JSON object. Measured against this machine's logs, asking for
836
+ * `daily,session` returns exactly what the two runs returned — `daily` (with
837
+ * its `agents` split when `--by-agent` rides along), `session`, and one
838
+ * `totals` — so the deck reads the same fields off one child.
839
+ *
840
+ * Not on `session` alone: the panel's totals and its per-model split come from
841
+ * `daily`, so `daily` is the command and the sessions are the extra section.
842
+ */
843
+ const SECTIONS = ["--sections", "daily,session"];
844
+ const blamesSections = (text) => /sections/i.test(String(text ?? ""));
845
+
846
+ /**
847
+ * A ccusage too old for `--sections`, remembered for the life of the process —
848
+ * the same narrow memory `_byAgentUnsupported` keeps, for the same reason: the
849
+ * retry costs one process on a machine that is already failing, and the memory
850
+ * costs the extra section for as long as the deck runs.
851
+ */
852
+ let _sectionsUnsupported = false;
853
+
826
854
  /**
827
855
  * Run `daily` for a range, asking for the per-agent split, and give up the
828
856
  * split rather than the whole answer when this ccusage will not produce one.
@@ -937,6 +965,38 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
937
965
  * waited its turn. With an empty queue that is the same instant the caller
938
966
  * asked, which is what this always did.
939
967
  */
968
+ /**
969
+ * The same range, grouped by session rather than by day.
970
+ *
971
+ * Returns `[]` for every failure, including a ccusage too old to have the
972
+ * subcommand. The panel's totals and its per-model split come from `daily`, and
973
+ * losing the session names must not lose those — an empty list draws one
974
+ * section short, which is the same thing that happens on a machine with no
975
+ * ccusage at all and is already a state the panel knows.
976
+ *
977
+ * WHAT `period` IS HERE, and it is the whole reason this is worth a second
978
+ * child: on a session row ccusage puts the SESSION ID in `period` — the same
979
+ * uuid Claude Code writes into every hook payload, and therefore the same key
980
+ * the canvas already files its agents under. So these rows join to the board by
981
+ * id, which is what lets the panel show ccusage's money against the deck's own
982
+ * project names. Without that join a session row is a uuid and a number.
983
+ */
984
+ async function readSessions(sinceArg, until) {
985
+ try {
986
+ const args = ["session", "--json", "--since", sinceArg];
987
+ if (until) args.push("--until", until);
988
+ const ran = await runCcusage(args);
989
+ const raw = extractJson(ran.out);
990
+ // `session`, singular — ccusage names the array after the command, not
991
+ // after its contents, and `sessions` reads as the obvious guess and is
992
+ // always undefined.
993
+ return Array.isArray(raw.session) ? raw.session : [];
994
+ } catch (err) {
995
+ note("session read failed", err);
996
+ return [];
997
+ }
998
+ }
999
+
940
1000
  async function readRange(sinceArg, until, key) {
941
1001
  const now = Date.now();
942
1002
  let result;
@@ -944,8 +1004,40 @@ async function readRange(sinceArg, until, key) {
944
1004
  try {
945
1005
  const args = ["daily", "--json", "--since", sinceArg];
946
1006
  if (until) args.push("--until", until);
947
- ran = await runDaily(args);
948
- const raw = extractJson(ran.out);
1007
+ let raw;
1008
+ if (_sectionsUnsupported) {
1009
+ ran = await runDaily(args);
1010
+ raw = extractJson(ran.out);
1011
+ } else {
1012
+ try {
1013
+ ran = await runDaily([...args, ...SECTIONS]);
1014
+ raw = extractJson(ran.out);
1015
+ } catch (err) {
1016
+ // A ccusage that could not run at all fails the same way with or
1017
+ // without the flag, so only a complaint naming the flag is worth a
1018
+ // second child — and `reason` set means the run never started, which is
1019
+ // not something a flag can fix.
1020
+ if (err?.reason !== undefined || !blamesSections(err?.message)) throw err;
1021
+ _sectionsUnsupported = true;
1022
+ ran = await runDaily(args);
1023
+ raw = extractJson(ran.out);
1024
+ }
1025
+ }
1026
+ // Already here on any ccusage that knows `--sections`: one load answered
1027
+ // both questions. The second child is the fallback for the older ones, and
1028
+ // it stays deliberately after the first rather than beside it — a failure
1029
+ // to name the sessions must not cost the totals, which are what the panel
1030
+ // is mostly for, and two ccusage processes at once on a cold machine is the
1031
+ // shape #476 spent a release removing from the boot path.
1032
+ // The second child belongs to the OLD ccusage and to nothing else. A build
1033
+ // that took `--sections` answered with the section — empty for a range with
1034
+ // no sessions in it — so a missing `session` key there means this deck
1035
+ // asked for something that build does not report, and asking again as a
1036
+ // separate command would get the same silence for the price of a second
1037
+ // walk of every transcript on the machine.
1038
+ const sessions = Array.isArray(raw.session)
1039
+ ? raw.session
1040
+ : (_sectionsUnsupported ? await readSessions(sinceArg, until) : []);
949
1041
  // Passed through whole, `agents` array and all. Every day ccusage returns
950
1042
  // under `--by-agent` is a superset of the day it returns without one, so
951
1043
  // there is nothing here to reshape: the browser reads the merged totals it
@@ -957,6 +1049,7 @@ async function readRange(sinceArg, until, key) {
957
1049
  result = {
958
1050
  ok: true,
959
1051
  days,
1052
+ sessions,
960
1053
  totals: raw.totals ?? null,
961
1054
  since: sinceArg,
962
1055
  until: until ?? null,
@@ -8,6 +8,8 @@
8
8
  // optional, unknown values pass through verbatim, and a `partial` flag tells
9
9
  // the UI when something was dropped instead of silently showing less.
10
10
  import { readFile } from "node:fs/promises";
11
+ // One clamp for both quota readers: see the note on cooldownFromHeader.
12
+ import { cooldownFromHeader } from "./quota.mjs";
11
13
  import { join } from "node:path";
12
14
  import { CODEX_HOME } from "./codex-dir.mjs";
13
15
  import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
@@ -335,8 +337,10 @@ async function doFetchCodexQuota() {
335
337
  // than the ordinary floor before asking again. `retry-after` is honoured when
336
338
  // the backend sends one, because it knows better than the constant does.
337
339
  const cooldown = (res) => {
338
- const after = parseInt(res?.headers?.get?.("retry-after") ?? "", 10);
339
- _rateLimitedUntil = Date.now() + (Number.isFinite(after) ? after * 1000 : COOLDOWN_MS);
340
+ // Clamped, for the reason quota.mjs states at cooldownFromHeader: a `0`
341
+ // defeats the cooldown a 429 exists to impose, and a day freezes this
342
+ // poller for the life of the process.
343
+ _rateLimitedUntil = Date.now() + cooldownFromHeader(res?.headers?.get?.("retry-after"), COOLDOWN_MS);
340
344
  };
341
345
 
342
346
  let auth, base, res;
@@ -201,11 +201,33 @@ async function readCompressedTokenSeries(filePath) {
201
201
  }
202
202
  return null;
203
203
  }
204
+ // THE SOURCE IS HELD, and both halves of that matter.
205
+ //
206
+ // `Readable.pipe` attaches its error handling to the DESTINATION. The source
207
+ // got neither an 'error' listener nor a destroy, so a read that failed — the
208
+ // file removed between the listing and this call, EACCES, EMFILE — emitted
209
+ // an unhandled 'error' and took the process down, in a reader whose
210
+ // uncompressed twin deliberately survives those same errnos. A torn archive
211
+ // errors on the destination instead, which the catch below swallows while the
212
+ // source stays open: twenty reads of one corrupt file left twenty handles,
213
+ // against a sixty-second poll. On Windows a held handle also blocks the
214
+ // unlink, which is the case the plain path's `finally { await fd?.close() }`
215
+ // names.
216
+ let source = null;
204
217
  try {
205
218
  const series = [];
206
219
  const decoder = new StringDecoder("utf8");
207
220
  let pending = "";
208
- const stream = createReadStream(filePath).pipe(createZstdDecompress());
221
+ source = createReadStream(filePath);
222
+ const stream = source.pipe(createZstdDecompress());
223
+ // THE ERROR HAS TO REACH THE LOOP, not merely be caught. A bare
224
+ // `on("error", () => {})` here stops the process dying and hangs the read
225
+ // instead: `pipe` does not forward a source failure to the destination, so
226
+ // the `for await` below waits forever for an 'end' that cannot come — which
227
+ // is what a missing file did on the first Windows run of this code, at 30
228
+ // seconds per poll. Destroying the destination WITH the error makes the
229
+ // iteration reject, which is what the catch is for.
230
+ source.on("error", err => { stream.destroy(err); });
209
231
  for await (const chunk of stream) {
210
232
  pending += decoder.write(chunk);
211
233
  let from = 0;
@@ -220,6 +242,7 @@ async function readCompressedTokenSeries(filePath) {
220
242
  if (pending) foldTokenLine(series, pending);
221
243
  return series.length ? series : null;
222
244
  } catch { return null; }
245
+ finally { source?.destroy(); }
223
246
  }
224
247
 
225
248
  /** The reader, exported under a test-only name. The compressed path cannot be
@@ -986,20 +986,6 @@ export async function shareAccounts(nums) {
986
986
  };
987
987
  }
988
988
 
989
- /**
990
- * The one-account case, which is a bundle of one and takes the same path.
991
- *
992
- * That path PARSES what cswap wrote, where this used to hand its stdout on
993
- * opaquely — the cost of `shared` being able to promise that the count on the
994
- * copy button is the count in the blob. So an export shape the fold cannot read
995
- * now fails the single share too, not just the bundle. Deliberate: one path
996
- * means the two cannot drift into two envelope shapes, and a share that
997
- * silently carried something this deck could not account for is the failure
998
- * the count exists to prevent.
999
- */
1000
- export async function shareAccount(num) {
1001
- return shareAccounts([num]);
1002
- }
1003
989
 
1004
990
  /**
1005
991
  * The identities a bundle carries, or `[]` when it cannot be read.
@@ -229,7 +229,13 @@ const MODEL_LIST_OK = /^(?!-)[A-Za-z0-9 ,._-]{1,120}$/;
229
229
 
230
230
  /** Validate against SETTINGS, then hand to `cswap config set`. */
231
231
  export async function setCswapConfig(key, value) {
232
- const spec = SETTINGS[key];
232
+ // `Object.hasOwn`, not a bare read. `SETTINGS["constructor"]` is truthy and
233
+ // its `.type` is undefined, so a prototype member passed the allowlist and
234
+ // fell through to the free-text branch — reaching `cswap config set
235
+ // constructor <value>` and skipping the type-specific range check on the way.
236
+ // Nothing reachable that way was dangerous; an allowlist that does not hold
237
+ // is.
238
+ const spec = Object.hasOwn(SETTINGS, key) ? SETTINGS[key] : null;
233
239
  if (!spec) return { ok: false, reason: "unknown_setting" };
234
240
 
235
241
  let str;
@@ -253,6 +259,17 @@ export async function setCswapConfig(key, value) {
253
259
  // nothing. The panel reloads this route immediately afterwards and gets a real
254
260
  // read; see invalidateCswapAutoCache.
255
261
  invalidateCswapAutoCache();
262
+ // A NEW INTERVAL HAS TO REACH THE TIMER. `tickInterval()` is read once, at
263
+ // startLoop, so changing this setting used to update what the panel reports
264
+ // and nothing else: set 3600 with auto-switch on and the panel read back an
265
+ // hour while the loop kept firing every sixty seconds for the life of the
266
+ // process — sixty `cswap auto --once` spawns an hour instead of one, against
267
+ // the shared per-account request budget this subsystem exists to protect.
268
+ // Lowering it was equally inert.
269
+ if (r.ok && key === "autoswitch.intervalSeconds" && _enabled) {
270
+ stopLoop();
271
+ await startLoop();
272
+ }
256
273
  return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
257
274
  }
258
275
 
@@ -291,6 +308,15 @@ function summarise(stdout) {
291
308
  /** Evaluate a tick for real. May switch the active account. */
292
309
  async function runAutoTick() {
293
310
  const r = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
311
+ // A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
312
+ // 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
313
+ // is false for a tick that emitted its `{"event":"poll"}` line and then
314
+ // stalled — and the killed run fell through to `ok: true`. The panel then
315
+ // showed a healthy `no-switch` every two minutes, forever, while the engine
316
+ // did nothing at all: exactly the trap exec.mjs's own header names.
317
+ if (r.timedOut) {
318
+ return { ok: false, reason: "tick_timeout", detail: `cswap auto --once did not finish within ${TICK_TIMEOUT_MS / 1000}s` };
319
+ }
294
320
  if (!r.ok && !r.stdout) {
295
321
  return { ok: false, reason: "tick_failed", detail: (r.stderr || "").trim().slice(0, 300) };
296
322
  }
@@ -477,6 +503,18 @@ async function runTick() {
477
503
  _lastTick = { at: Date.now(), event: "skipped", reason: "external-engine" };
478
504
  return;
479
505
  }
506
+ // AND RE-CHECK THE SWITCH ITSELF, after that await. `stopLoop` clears the
507
+ // interval and nothing else, so a tick already running went on to move the
508
+ // user's live account seconds after the panel had drawn itself as off. The
509
+ // await above is not short: ticks are at least fifteen seconds apart against
510
+ // a ten-second floor, so every one pays a real process-table read — on
511
+ // Windows a PowerShell Get-CimInstance with an eight-second deadline. The
512
+ // panel then showed `enabled: false` beside a `lastTick` of
513
+ // `{event: "switch", from, to}` stamped after the user turned it off.
514
+ if (!_enabled) {
515
+ _lastTick = { at: Date.now(), event: "skipped", reason: "disabled" };
516
+ return;
517
+ }
480
518
  const result = await runAutoTick();
481
519
  // Before `_lastTick`, not after. This is the only path in the deck that moves
482
520
  // the live account without a click behind it, so nothing else is in a position
@@ -597,8 +597,13 @@ export async function ensureCswap({ onInstalling = null } = {}) {
597
597
  // Installed — the only question left is whether it's stale. One PyPI
598
598
  // request a day, and the upgrade itself never blocks startup.
599
599
  if (!updateCheckDue()) return { state: "present", version: existing };
600
- touchMarker();
600
+ // The marker is stamped AFTER the request, not before it. Stamped first, a
601
+ // boot with no network yet — a laptop opened on a train, the ten seconds
602
+ // before Wi-Fi associates — burned the whole shared 24-hour window on a
603
+ // check that never reached PyPI, and the next real chance was the day
604
+ // after. self-update.mjs states this rule for itself in as many words.
601
605
  const latest = await latestOnPypi();
606
+ touchMarker();
602
607
  if (latest && existing !== "installed" && isOlder(existing, latest)) {
603
608
  // Who owns it, not what is installed on the machine: an upgrade aimed at
604
609
  // a tool that never installed this package is refused where runDetached
@@ -28,6 +28,30 @@
28
28
  import { execFile, spawn } from "node:child_process";
29
29
  import { existsSync } from "node:fs";
30
30
 
31
+ // WINDOWS SEARCHES THE WORKING DIRECTORY FIRST, and this turns that off.
32
+ //
33
+ // libuv's PATH search calls `NeedCurrentDirectoryForExePathW("")`, which is
34
+ // true unless this variable is set — so `spawn("cswap", …)` on Windows tries
35
+ // `.\cswap.exe` before anything on PATH, and the deck's working directory is
36
+ // wherever `npx ccdeck` was run: normally the user's project, often a repo they
37
+ // just cloned. `cswapBin()` probes the bare name before any known install path
38
+ // and memoises whatever answered, so a planted binary would then receive every
39
+ // later `cswap switch` and `cswap export -` — the commands that carry account
40
+ // credentials. The same search reaches py.exe, where.exe, claude.exe and
41
+ // powershell.exe.
42
+ //
43
+ // Set on this process rather than per spawn, because the search is done by the
44
+ // PARENT: one assignment covers every child this deck ever starts, including
45
+ // the ones spawned outside this module. An explicit value in the environment is
46
+ // left alone — someone who set it meant it.
47
+ //
48
+ // POSIX never had this behaviour: execvp does not search `.` unless PATH says
49
+ // so, so this is a no-op there and is skipped rather than written.
50
+ if (process.platform === "win32" && !("NoDefaultCurrentDirectoryInExePath" in process.env)) {
51
+ process.env.NoDefaultCurrentDirectoryInExePath = "1";
52
+ }
53
+
54
+
31
55
  // Extensions Windows will execute, most specific first. `.com` is omitted —
32
56
  // nothing ships one, and every extra candidate costs a failed spawn.
33
57
  const WIN_EXTS = [".exe", ".cmd", ".bat", ""];
@@ -682,6 +706,12 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
682
706
  // the shell command it replaced had to end in `< /dev/null`. Closing
683
707
  // the pipe is that redirection without a shell to parse it.
684
708
  try { cp.stdin?.on("error", () => {}); cp.stdin?.end(); } catch { /* no stdin to close */ }
709
+ // Decoded as a stream rather than per chunk: a chunk boundary falls
710
+ // wherever the pipe broke, and a multi-byte character split across two
711
+ // of them becomes two replacement characters in the tail this keeps for
712
+ // the timeout message.
713
+ cp.stdout?.setEncoding?.("utf8");
714
+ cp.stderr?.setEncoding?.("utf8");
685
715
  cp.stdout?.on("data", (d) => { sawOut = (sawOut + d).slice(-TIMEOUT_TAIL); });
686
716
  cp.stderr?.on("data", (d) => { sawErr = (sawErr + d).slice(-TIMEOUT_TAIL); });
687
717
  // The deadline states the outcome itself and only then kills, which is
@@ -814,6 +844,15 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
814
844
  return tryNext(err) ? attempt(i + 1) : finish(-1, err);
815
845
  }
816
846
  child = proc;
847
+ // EPIPE ON STDIN IS NOT A CRASH. `write` below is wrapped in a try/catch,
848
+ // and that catch can never fire for the case that matters: a broken pipe
849
+ // arrives asynchronously, as an 'error' event on the Writable, and an
850
+ // unhandled 'error' on a stream is an uncaught exception with no
851
+ // process-level net anywhere in this deck. `cswap import -` reading a
852
+ // prefix of a bad bundle and exiting before it drains is exactly that
853
+ // shape, so a rejected paste in the import dialog took the dashboard down
854
+ // with it. `run` has carried this same line since it was written.
855
+ proc.stdin?.on("error", () => {});
817
856
  // A spelling that fails to spawn emits 'error' AND THEN 'close' — with code
818
857
  // -2 after an ENOENT. Once the error handler has moved on to the next
819
858
  // candidate, that trailing 'close' is news about a child nobody is waiting
@@ -845,6 +884,11 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
845
884
  // Capped so a runaway child cannot grow the heap without bound; the tail is
846
885
  // what carries the error, so the head is what gets dropped.
847
886
  const keep = (buf, text) => (buf + text).slice(-maxOutput);
887
+ // Same reason as `run`'s tails: the login prompt this reader is waiting for
888
+ // arrives mid-chunk, and a UTF-8 sequence cut by a pipe boundary must not
889
+ // become two replacement characters in the line it emits.
890
+ proc.stdout?.setEncoding?.("utf8");
891
+ proc.stderr?.setEncoding?.("utf8");
848
892
  proc.stdout?.on("data", (d) => { if (stale()) return; const t = String(d); stdout = keep(stdout, t); emitLines(t); });
849
893
  proc.stderr?.on("data", (d) => { if (stale()) return; const t = String(d); stderr = keep(stderr, t); emitLines(t); });
850
894
  proc.on("close", (code) => {