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.
- package/README.md +18 -6
- package/bin/deck.js +9 -1
- package/dist/web/assets/index-GLqupad3.css +1 -0
- package/dist/web/assets/index-Z0vOy1A6.js +165 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +60 -14
- package/package.json +6 -3
- package/release-notes.json +32 -0
- package/src/server/agent-activity.mjs +27 -11
- package/src/server/browser-history.mjs +31 -5
- package/src/server/browser-presence.mjs +57 -12
- package/src/server/browser-react.mjs +31 -9
- package/src/server/browser-watch-store.mjs +56 -2
- package/src/server/browser-watch.mjs +90 -10
- package/src/server/ccusage.mjs +95 -2
- package/src/server/codex-quota.mjs +6 -2
- package/src/server/codex-usage.mjs +24 -1
- package/src/server/cswap-admin.mjs +0 -14
- package/src/server/cswap-auto.mjs +39 -1
- package/src/server/cswap-install.mjs +6 -1
- package/src/server/exec.mjs +44 -0
- package/src/server/index.mjs +127 -11
- package/src/server/installer.mjs +58 -3
- package/src/server/macmon.mjs +17 -6
- package/src/server/npx.mjs +6 -1
- package/src/server/open-url.mjs +25 -4
- package/src/server/quota.mjs +88 -26
- package/src/server/retire-sound-hook.mjs +36 -2
- package/src/server/self-update.mjs +17 -5
- package/src/server/system-metrics.mjs +28 -36
- package/src/server/uv-bootstrap.mjs +39 -3
- package/dist/web/assets/index-7UsJp5Ht.js +0 -143
- package/dist/web/assets/index-Bnn7d7u8.css +0 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
469
|
-
//
|
|
470
|
-
|
|
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
|
-
|
|
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
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -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
|
-
|
|
948
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/server/exec.mjs
CHANGED
|
@@ -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) => {
|