agent-dag 1.44.1 → 1.45.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 +3 -1
- package/bin/agent-dag.js +25 -1
- package/bin/deck.js +98 -15
- package/dist/web/assets/{index-XtT5NdJI.css → index-Bdl1LX0-.css} +1 -1
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +1 -1
- package/src/server/args.mjs +113 -15
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-usage.mjs +65 -7
- package/src/server/cswap-admin.mjs +166 -34
- package/src/server/cswap-auto.mjs +156 -2
- package/src/server/exec.mjs +103 -15
- package/src/server/index.mjs +1793 -163
- package/src/server/installer.mjs +119 -30
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/supervisor.mjs +67 -0
- package/dist/web/assets/index-DBsxIfdM.js +0 -78
- package/hook/notify.mjs +0 -104
- package/src/server/sound-hook.mjs +0 -518
|
@@ -374,7 +374,14 @@ export async function startLogin({ email } = {}) {
|
|
|
374
374
|
// waiting only for a url meant this POST sat open for the whole fifteen
|
|
375
375
|
// seconds afterwards: a spinner in front of a user whose answer was ready
|
|
376
376
|
// almost immediately.
|
|
377
|
-
await waitFor(() => flow.url || flow.state === "failed", 15_000);
|
|
377
|
+
await waitFor(() => flow.url || flow.state === "failed" || flow.state === "done", 15_000);
|
|
378
|
+
// A sign-in that got somewhere without a link this could read is still a
|
|
379
|
+
// sign-in, and since #708 the done handler can carry one all the way to
|
|
380
|
+
// `done` on its own. Saying "no url" over that would throw a completed login
|
|
381
|
+
// away at the last step, which is the whole of the bug being fixed.
|
|
382
|
+
if (!flow.url && (flow.state === "registering" || flow.state === "done")) {
|
|
383
|
+
return { ok: true, ...loginState() };
|
|
384
|
+
}
|
|
378
385
|
if (!flow.url) {
|
|
379
386
|
flow.child.kill();
|
|
380
387
|
// Same identity check as the done handler below, and for a sharper reason:
|
|
@@ -467,18 +474,136 @@ async function spawnLogin(email) {
|
|
|
467
474
|
// A newline ended that line; whatever comes next is a new one.
|
|
468
475
|
if (!partial) countedOnLine = 0;
|
|
469
476
|
});
|
|
470
|
-
// The child
|
|
471
|
-
//
|
|
472
|
-
|
|
477
|
+
// The child ending before a code was pasted USED to be read as a failure, and
|
|
478
|
+
// on this CLI that is the ordinary end of a sign-in that worked (#708).
|
|
479
|
+
//
|
|
480
|
+
// `claude auth login` 2.1.246 does two things at once: it prints
|
|
481
|
+
// "Paste code here if prompted > " and blocks on stdin, AND it listens on a
|
|
482
|
+
// loopback port for the OAuth callback. Which half finishes the exchange is
|
|
483
|
+
// not decided by the CLI's version — it is decided by whether the browser
|
|
484
|
+
// that opened can reach this machine's loopback. On the deck's own machine it
|
|
485
|
+
// can, so the CLI takes the code itself, prints "Login successful." and exits
|
|
486
|
+
// 0 while the deck is still sitting in `awaiting_code`; the page that
|
|
487
|
+
// authorised says "You're all set up" and never shows a code to paste. On a
|
|
488
|
+
// deck reached from another machine it cannot, the page shows the code, and
|
|
489
|
+
// the paste path below is the one that runs.
|
|
490
|
+
//
|
|
491
|
+
// So the exit alone is not the verdict. The verdict is the identity, and
|
|
492
|
+
// `claude auth status --json` is the oracle this module already trusts for
|
|
493
|
+
// it — asked here against the identity recorded before the flow started.
|
|
494
|
+
child.done.then(async (r) => {
|
|
473
495
|
if (flow !== _login) return;
|
|
474
|
-
if (flow.state
|
|
496
|
+
if (flow.state !== "awaiting_url" && flow.state !== "awaiting_code") return;
|
|
497
|
+
// Answered before any await, so that a `claude` which cannot be run at all
|
|
498
|
+
// is still reported within milliseconds: startLogin waits on this state to
|
|
499
|
+
// decide whether to keep holding its POST open, and the sentence naming
|
|
500
|
+
// AGENTS_DECK_CLAUDE is the only one in the flow that names a fix. Nothing
|
|
501
|
+
// was signed in either way, so there is nothing to ask about.
|
|
502
|
+
if (r.timedOut || cannotRun(r)) {
|
|
475
503
|
flow.state = "failed";
|
|
476
|
-
flow.error =
|
|
504
|
+
flow.error = loginFailureText(r);
|
|
505
|
+
return;
|
|
477
506
|
}
|
|
507
|
+
// Claimed before asking, for the same reason submitLoginCode claims it: a
|
|
508
|
+
// code posted while the question is out would otherwise register the same
|
|
509
|
+
// login a second time, with a second `cswap add` racing this one.
|
|
510
|
+
flow.state = "registering";
|
|
511
|
+
const identity = await currentIdentity();
|
|
512
|
+
if (flow !== _login) return;
|
|
513
|
+
// A clean exit with somebody logged in is a completed sign-in, including
|
|
514
|
+
// the re-sign-in of an account the deck already holds — an identity that
|
|
515
|
+
// did not CHANGE is not an identity that did not arrive. A dirty exit
|
|
516
|
+
// counts only when the identity moved, which is a login that landed in
|
|
517
|
+
// spite of whatever the CLI complained about on its way out.
|
|
518
|
+
if (identity && (r.ok || identity.email !== flow.previousEmail)) {
|
|
519
|
+
await registerSignedIn(flow, identity);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
flow.state = "failed";
|
|
523
|
+
flow.error = loginFailureText(r);
|
|
524
|
+
}).catch((err) => {
|
|
525
|
+
// This handler answers nobody's request — its promise is dropped — so a
|
|
526
|
+
// throw anywhere in it would be an unhandled rejection AND a dialog left
|
|
527
|
+
// spinning on `registering` until the poll gave up. It was three lines of
|
|
528
|
+
// synchronous assignment before; it now shells out twice.
|
|
529
|
+
console.error(`${PRODUCT} sign-in: the sign-in could not be finished:`, err?.message ?? err);
|
|
530
|
+
if (flow !== _login) return;
|
|
531
|
+
flow.state = "failed";
|
|
532
|
+
flow.error = "the sign-in could not be finished — see the deck's log";
|
|
478
533
|
});
|
|
479
534
|
return flow;
|
|
480
535
|
}
|
|
481
536
|
|
|
537
|
+
/** A run that never reached the CLI at all — nothing can have been signed in. */
|
|
538
|
+
function cannotRun(r) {
|
|
539
|
+
return r?.code === "ENOENT" || looksMissing(`${r?.stderr ?? ""}\n${r?.stdout ?? ""}`, "", r?.code);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Why a sign-in failed, in words meant for the person who pressed the button.
|
|
544
|
+
*
|
|
545
|
+
* The child's own output is offered only when it is a diagnosis — see
|
|
546
|
+
* failureText, which since #708 refuses a line that announces success or is
|
|
547
|
+
* merely the prompt the CLI was still sitting on. What it printed is not thrown
|
|
548
|
+
* away; it goes to the deck's log, where an operator can read it, rather than
|
|
549
|
+
* onto a dialog as the reason.
|
|
550
|
+
*/
|
|
551
|
+
function loginFailureText(r) {
|
|
552
|
+
// One line, the way ccusage's `note` says everything it says: an operator
|
|
553
|
+
// watching the terminal is reading it beside the deck's own repainted status
|
|
554
|
+
// rows, and the escapes in it are a login link's OSC-8 wrapper.
|
|
555
|
+
//
|
|
556
|
+
// stderr goes LAST because the line is cut from the front. What should be
|
|
557
|
+
// lost to the bound is the CLI's chatter — the greeting and a sign-in link
|
|
558
|
+
// that is 400 characters by itself — never its complaint.
|
|
559
|
+
const tail = stripTerminalEscapes(`${r?.stdout ?? ""}\n${r?.stderr ?? ""}`).replace(/\s+/g, " ").trim();
|
|
560
|
+
if (tail) console.error(`${PRODUCT} sign-in: claude auth login did not complete:`, tail.slice(-300));
|
|
561
|
+
if (r?.timedOut) return "the sign-in window expired";
|
|
562
|
+
return failureText(r, "claude auth login", "the sign-in did not complete — nothing new was signed in");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Everything after the sign-in itself: confirm who we are now, record it with
|
|
567
|
+
* claude-swap, and put the previously-active account back in front.
|
|
568
|
+
*
|
|
569
|
+
* Shared by the two ways a sign-in can end (#708) — a code pasted into the
|
|
570
|
+
* prompt, and the CLI finishing the exchange through its own loopback callback
|
|
571
|
+
* — because the steps after it are identical and have to stay identical. An
|
|
572
|
+
* account the deck skipped `cswap add` for is signed in at the CLI level and
|
|
573
|
+
* invisible to the panel, with the account the user was on left switched away
|
|
574
|
+
* from.
|
|
575
|
+
*
|
|
576
|
+
* Returns the same `{ok, ...loginState()}` both callers answer their request
|
|
577
|
+
* with; the done handler simply drops it.
|
|
578
|
+
*/
|
|
579
|
+
async function registerSignedIn(flow, identity) {
|
|
580
|
+
return withStoreLock(async () => {
|
|
581
|
+
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
582
|
+
if (!add.ok) {
|
|
583
|
+
flow.state = "failed";
|
|
584
|
+
flow.error = addFailureText(add);
|
|
585
|
+
await restoreActive(flow.previousActive);
|
|
586
|
+
return { ok: false, reason: "add_failed", ...loginState() };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const after = await readStore();
|
|
590
|
+
const slot = newSlot(flow.before, after);
|
|
591
|
+
// No new slot means the account was already managed and cswap refreshed its
|
|
592
|
+
// credentials in place. That is a success with a different sentence.
|
|
593
|
+
const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
|
|
594
|
+
|
|
595
|
+
await restoreActive(flow.previousActive);
|
|
596
|
+
invalidateClaudeAccountsCache();
|
|
597
|
+
// Collect straight away, so the new row shows numbers instead of "never
|
|
598
|
+
// collected" until the next poll — the same nudge seedFirstAccount uses.
|
|
599
|
+
runDetached(await cswapBin(), ["list"]);
|
|
600
|
+
|
|
601
|
+
flow.state = "done";
|
|
602
|
+
flow.account = { num, email: identity.email, added: slot != null };
|
|
603
|
+
return { ok: true, ...loginState() };
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
482
607
|
/**
|
|
483
608
|
* Hand the code back, then register whatever it signed us in as.
|
|
484
609
|
*
|
|
@@ -515,7 +640,11 @@ export async function submitLoginCode(code) {
|
|
|
515
640
|
}
|
|
516
641
|
if (!r.ok) {
|
|
517
642
|
flow.state = "failed";
|
|
518
|
-
|
|
643
|
+
// The `|| "the code was not accepted"` this used to end with could never
|
|
644
|
+
// run: failureText always answered with something, if only an exit status.
|
|
645
|
+
// The sentence is where it can be reached now — as the fallback failureText
|
|
646
|
+
// reaches for when the CLI printed nothing worth repeating.
|
|
647
|
+
flow.error = r.timedOut ? "the sign-in window expired" : failureText(r, "claude auth login", "the code was not accepted");
|
|
519
648
|
return { ok: false, reason: "login_failed", ...loginState() };
|
|
520
649
|
}
|
|
521
650
|
|
|
@@ -526,31 +655,7 @@ export async function submitLoginCode(code) {
|
|
|
526
655
|
return { ok: false, reason: "no_identity", ...loginState() };
|
|
527
656
|
}
|
|
528
657
|
|
|
529
|
-
return
|
|
530
|
-
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
531
|
-
if (!add.ok) {
|
|
532
|
-
flow.state = "failed";
|
|
533
|
-
flow.error = addFailureText(add);
|
|
534
|
-
await restoreActive(flow.previousActive);
|
|
535
|
-
return { ok: false, reason: "add_failed", ...loginState() };
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const after = await readStore();
|
|
539
|
-
const slot = newSlot(flow.before, after);
|
|
540
|
-
// No new slot means the account was already managed and cswap refreshed its
|
|
541
|
-
// credentials in place. That is a success with a different sentence.
|
|
542
|
-
const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
|
|
543
|
-
|
|
544
|
-
await restoreActive(flow.previousActive);
|
|
545
|
-
invalidateClaudeAccountsCache();
|
|
546
|
-
// Collect straight away, so the new row shows numbers instead of "never
|
|
547
|
-
// collected" until the next poll — the same nudge seedFirstAccount uses.
|
|
548
|
-
runDetached(await cswapBin(), ["list"]);
|
|
549
|
-
|
|
550
|
-
flow.state = "done";
|
|
551
|
-
flow.account = { num, email: identity.email, added: slot != null };
|
|
552
|
-
return { ok: true, ...loginState() };
|
|
553
|
-
});
|
|
658
|
+
return registerSignedIn(flow, identity);
|
|
554
659
|
}
|
|
555
660
|
|
|
556
661
|
export async function cancelLogin() {
|
|
@@ -850,7 +955,7 @@ export async function moveAccount(num, slot) {
|
|
|
850
955
|
* in every language. Without it, a German user pressing "share…" got the last
|
|
851
956
|
* line of a translated sentence instead of the sentence about PATH.
|
|
852
957
|
*/
|
|
853
|
-
export function failureText(r, what = "cswap") {
|
|
958
|
+
export function failureText(r, what = "cswap", fallback = "") {
|
|
854
959
|
const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
|
|
855
960
|
const tool = String(what).split(" ")[0];
|
|
856
961
|
if (r?.code === "ENOENT" || looksMissing(out, "", r?.code)) {
|
|
@@ -863,7 +968,34 @@ export function failureText(r, what = "cswap") {
|
|
|
863
968
|
// Asked for one anyway, this said "cswap export exited 0", a success code for
|
|
864
969
|
// a command that never completed.
|
|
865
970
|
if (r?.timedOut || r?.code === "ETIMEDOUT") return `${what} took too long and was stopped`;
|
|
866
|
-
return
|
|
971
|
+
return diagnosis(r?.stderr) || diagnosis(r?.stdout) || fallback || `${what} exited ${r?.code}`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* A line that is NOT a diagnosis, however it reached us.
|
|
976
|
+
*
|
|
977
|
+
* Two shapes were being shown to users as the reason something failed (#708),
|
|
978
|
+
* both out of `claude auth login`: the CLI's own "Login successful." — a
|
|
979
|
+
* failure reason containing the word "successful" is not a diagnosis, it is a
|
|
980
|
+
* dump — and "Paste code here if prompted >", the unterminated prompt it was
|
|
981
|
+
* still sitting on, which says that something was ASKED and nothing about
|
|
982
|
+
* anything going wrong.
|
|
983
|
+
*/
|
|
984
|
+
const NOT_A_DIAGNOSIS = /\bsuccess(?:ful|fully)?\b|[>?]\s*$/i;
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* One stream's last useful line, if it is worth showing a person.
|
|
988
|
+
*
|
|
989
|
+
* Split per stream because the two are not equal: a CLI's diagnosis goes to
|
|
990
|
+
* stderr and its ordinary progress chatter goes to stdout. Reading the LAST
|
|
991
|
+
* line of the two concatenated — which is what this module did — handed stdout
|
|
992
|
+
* the answer whenever it had written anything at all, so `claude auth login`
|
|
993
|
+
* explained itself with the prompt it had printed rather than with the "Login
|
|
994
|
+
* failed: …" it had put on stderr.
|
|
995
|
+
*/
|
|
996
|
+
function diagnosis(text) {
|
|
997
|
+
const line = firstUseful(text);
|
|
998
|
+
return line && !NOT_A_DIAGNOSIS.test(line) ? line : "";
|
|
867
999
|
}
|
|
868
1000
|
|
|
869
1001
|
/** The line worth showing a user out of a CLI's output. */
|
|
@@ -34,6 +34,139 @@ const SETTINGS = {
|
|
|
34
34
|
"autoswitch.model": { type: "model" },
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
// ── one reading at a time ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* #616: /api/cswap-auto is a GET with no cache, no dedupe and no throttle, and
|
|
41
|
+
* autoStatus() runs BOTH of this module's readers on every one of them — so the
|
|
42
|
+
* number of children was exactly twice the number of requests.
|
|
43
|
+
*
|
|
44
|
+
* Measured on macOS with claude-swap installed, counting real children through a
|
|
45
|
+
* PATH shim: one autoStatus() is 2 children (`cswap config` and `ps -Ao args=`)
|
|
46
|
+
* and about 190ms warm; two back-to-back calls are 4; twenty-five concurrent
|
|
47
|
+
* readers produced 50 — twenty-five Python interpreters and twenty-five `ps` —
|
|
48
|
+
* and took 1.3 to 1.9s between them against 190ms for one, so the cost per
|
|
49
|
+
* reader grows rather than holds. With the guard below the same twenty-five are
|
|
50
|
+
* 2 children and 190ms, which is one reader's worth.
|
|
51
|
+
*
|
|
52
|
+
* On Windows the process-table half is `Get-CimInstance Win32_Process` through
|
|
53
|
+
* PowerShell, carrying an 8s deadline of its own, which is the same order of
|
|
54
|
+
* cost as the Get-Process #544 measured at about six seconds; and where cswap is
|
|
55
|
+
* not on PATH each call also re-pays cswapBin()'s probe, which memoizes only
|
|
56
|
+
* success and which `candidates` expands to four spellings there, two of them
|
|
57
|
+
* launched through cmd.exe.
|
|
58
|
+
*
|
|
59
|
+
* Two callers reach these without an attacker anywhere: AccountsPanel polls the
|
|
60
|
+
* route every 15s per open tab, and runTick asks externalAutoRunning() again
|
|
61
|
+
* before every tick. And it is a GET, so it passes isTrustedRead for any local
|
|
62
|
+
* client that sends neither Origin nor Sec-Fetch-Site — curl, a shell script, a
|
|
63
|
+
* sandboxed agent.
|
|
64
|
+
*
|
|
65
|
+
* The fix is #544's, at the route that sweep did not reach: a minimum gap plus
|
|
66
|
+
* one shared in-flight promise per reader. There is no MAX_OUTSTANDING beside it
|
|
67
|
+
* the way ccusage.mjs has one, and there does not need to be — ccusage keys its
|
|
68
|
+
* cache by date range, so a flood of distinct ranges can never share a run,
|
|
69
|
+
* while each reader here asks exactly one question and every caller of it can
|
|
70
|
+
* therefore join the same child.
|
|
71
|
+
*
|
|
72
|
+
* What is NOT shared is the window, because the two halves are not the same
|
|
73
|
+
* question. See CONFIG_MIN_GAP_MS and EXTERNAL_MIN_GAP_MS.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `cswap config` — the settings map, which is also what the panel DISPLAYS.
|
|
78
|
+
*
|
|
79
|
+
* The deck is not its only writer: `cswap config set` typed in a terminal
|
|
80
|
+
* changes it behind the deck's back, and the panel is where the user would
|
|
81
|
+
* expect to see that. So this window has to stay well under AccountsPanel's
|
|
82
|
+
* 15s poll, or an edit made outside the deck waits for the window AND the poll.
|
|
83
|
+
* Three seconds does not delay a single tab by one frame — its polls are five
|
|
84
|
+
* gaps apart — while a burst of requests and two tabs whose polls land within
|
|
85
|
+
* three seconds of each other collapse onto one child.
|
|
86
|
+
*/
|
|
87
|
+
const CONFIG_MIN_GAP_MS = 3_000;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The process table — the expensive half, and the one whose answer changes
|
|
91
|
+
* least: it is a boolean about whether the user has their own `cswap auto`
|
|
92
|
+
* running, and nobody starts one between two fifteen-second polls.
|
|
93
|
+
*
|
|
94
|
+
* Ten seconds is chosen against the two scheduled callers rather than against
|
|
95
|
+
* the cost: AccountsPanel's poll is 15s and MIN_INTERVAL_S — claude-swap's own
|
|
96
|
+
* floor, and the smallest tick interval SETTINGS will accept — is also 15, so a
|
|
97
|
+
* gap below both means neither of them is ever handed a reading older than its
|
|
98
|
+
* own period. The deck's tick still decides on a fresh process table, and the
|
|
99
|
+
* panel still shows one; what disappears is the second, third and twenty-fifth
|
|
100
|
+
* copy taken in the same ten seconds.
|
|
101
|
+
*
|
|
102
|
+
* Worst case for a caller in a loop is now 20 `cswap config` and 6 process-table
|
|
103
|
+
* children a minute, whatever it asks for, against a pair per request before.
|
|
104
|
+
*/
|
|
105
|
+
const EXTERNAL_MIN_GAP_MS = 10_000;
|
|
106
|
+
|
|
107
|
+
const _config = { last: null, inFlight: null };
|
|
108
|
+
const _external = { last: null, inFlight: null };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One reading of `read`, shared by everyone who asks inside `gapMs`.
|
|
112
|
+
*
|
|
113
|
+
* Only a real reading is remembered, which is what `value != null` means here:
|
|
114
|
+
* readCswapConfig spells its failure `null` — autoStatus reports
|
|
115
|
+
* `ok: config != null`, so holding one for three seconds would turn a single
|
|
116
|
+
* hiccup into a panel that renders itself as broken for longer than the hiccup
|
|
117
|
+
* lasted — and externalAutoRunning has no failure spelling at all, answering
|
|
118
|
+
* `false` for a process table it could not read because that is the same answer
|
|
119
|
+
* as an empty one and is the safe one either way. The in-flight share still
|
|
120
|
+
* applies to a failing read, so a burst arriving during one is a single failing
|
|
121
|
+
* child rather than a burst of them.
|
|
122
|
+
*
|
|
123
|
+
* `slot.inFlight === mine` on both hops is claude-accounts.mjs's guard and is
|
|
124
|
+
* here for its reason: invalidateCswapAutoCache drops `inFlight` so the next
|
|
125
|
+
* caller starts a read that knows the settings moved, and a read from BEFORE the
|
|
126
|
+
* write must neither store its answer under the new state nor clear the new
|
|
127
|
+
* read's promise on its way out.
|
|
128
|
+
*
|
|
129
|
+
* The reading is not keyed by platform even though externalAutoRunning branches
|
|
130
|
+
* on one. A process does not change platform; the two test files that flip
|
|
131
|
+
* `process.platform` to reach the other half from this one call
|
|
132
|
+
* invalidateCswapAutoCache between cases.
|
|
133
|
+
*/
|
|
134
|
+
function throttled(slot, gapMs, read) {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
if (slot.last && now - slot.last.at < gapMs) return Promise.resolve(slot.last.value);
|
|
137
|
+
if (slot.inFlight) return slot.inFlight;
|
|
138
|
+
const mine = read()
|
|
139
|
+
.then(value => {
|
|
140
|
+
if (value != null && slot.inFlight === mine) slot.last = { at: Date.now(), value };
|
|
141
|
+
return value;
|
|
142
|
+
})
|
|
143
|
+
.finally(() => { if (slot.inFlight === mine) slot.inFlight = null; });
|
|
144
|
+
slot.inFlight = mine;
|
|
145
|
+
return mine;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Forget both readings, because the deck has just changed what they would say.
|
|
150
|
+
*
|
|
151
|
+
* The one caller is setCswapConfig. There is no `?refresh=1` on /api/cswap-auto
|
|
152
|
+
* and no force argument through autoStatus, because the panel's explicit-refresh
|
|
153
|
+
* path is not a query parameter: every auto-switch control is a POST followed by
|
|
154
|
+
* `load(true)`, which re-fetches this route. Dropping the reading inside the
|
|
155
|
+
* write is what makes that reload show what was written rather than the map read
|
|
156
|
+
* a moment before it — the same disagreement between an optimistic value and the
|
|
157
|
+
* next read that #584 was.
|
|
158
|
+
*
|
|
159
|
+
* The process-table reading goes with it. A settings write does not start
|
|
160
|
+
* anybody's `cswap auto`, so this is not correctness for that half — it is that
|
|
161
|
+
* one function which forgets everything this module is holding cannot be called
|
|
162
|
+
* half-right, and the cost is at most one extra `ps` on a path the user reached
|
|
163
|
+
* by clicking. It is also what the tests reset between cases.
|
|
164
|
+
*/
|
|
165
|
+
export function invalidateCswapAutoCache() {
|
|
166
|
+
_config.last = _config.inFlight = null;
|
|
167
|
+
_external.last = _external.inFlight = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
37
170
|
// ── settings ───────────────────────────────────────────────────────────────
|
|
38
171
|
|
|
39
172
|
/**
|
|
@@ -47,8 +180,15 @@ const SETTINGS = {
|
|
|
47
180
|
* clamped anyway. The parse itself is a regex over human-formatted output from a
|
|
48
181
|
* separate Python tool, on both line-ending conventions. See
|
|
49
182
|
* cswap-auto-readers.test.ts.
|
|
183
|
+
*
|
|
184
|
+
* One reading at a time and one every CONFIG_MIN_GAP_MS at most; the parse below
|
|
185
|
+
* is what a reading is, and admission control is the wrapper. See throttled.
|
|
50
186
|
*/
|
|
51
|
-
export
|
|
187
|
+
export function readCswapConfig() {
|
|
188
|
+
return throttled(_config, CONFIG_MIN_GAP_MS, readCswapConfigNow);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readCswapConfigNow() {
|
|
52
192
|
const r = await run(await cswapBin(), ["config"]);
|
|
53
193
|
if (!r.ok) return null;
|
|
54
194
|
const out = {};
|
|
@@ -107,6 +247,12 @@ export async function setCswapConfig(key, value) {
|
|
|
107
247
|
}
|
|
108
248
|
|
|
109
249
|
const r = await run(await cswapBin(), ["config", "set", key, str]);
|
|
250
|
+
// Whatever the CLI said. A write that reported a failure may still have landed
|
|
251
|
+
// — and `r.ok` is not proof either way here, which is the whole of #584 — so
|
|
252
|
+
// the only safe thing to hold after asking cswap to change a setting is
|
|
253
|
+
// nothing. The panel reloads this route immediately afterwards and gets a real
|
|
254
|
+
// read; see invalidateCswapAutoCache.
|
|
255
|
+
invalidateCswapAutoCache();
|
|
110
256
|
return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
111
257
|
}
|
|
112
258
|
|
|
@@ -230,8 +376,16 @@ export function looksLikeAutoLoop(line) {
|
|
|
230
376
|
* two halves also run completely different commands, `ps` against
|
|
231
377
|
* `Get-CimInstance`, so on any one machine only half of it is ever exercised at
|
|
232
378
|
* all. See cswap-auto-readers.test.ts, which drives both from either host.
|
|
379
|
+
*
|
|
380
|
+
* One reading at a time and one every EXTERNAL_MIN_GAP_MS at most — the
|
|
381
|
+
* expensive half of #616, and the one both of its callers ask for on a
|
|
382
|
+
* fifteen-second timer. See throttled.
|
|
233
383
|
*/
|
|
234
|
-
export
|
|
384
|
+
export function externalAutoRunning() {
|
|
385
|
+
return throttled(_external, EXTERNAL_MIN_GAP_MS, externalAutoRunningNow);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function externalAutoRunningNow() {
|
|
235
389
|
// A line is the user's loop if it runs `cswap auto` without --once. Our own
|
|
236
390
|
// ticks are --once, and so is a cron user's. See looksLikeAutoLoop.
|
|
237
391
|
const isLoop = looksLikeAutoLoop;
|
package/src/server/exec.mjs
CHANGED
|
@@ -63,8 +63,9 @@ export const isBatch = (file, platform = process.platform) =>
|
|
|
63
63
|
* Mirrors what Node does internally for `shell: true` on Windows — comspec,
|
|
64
64
|
* /d /s /c, the whole command line as a single quoted argument, and
|
|
65
65
|
* windowsVerbatimArguments so Node does not quote it a second time. Each
|
|
66
|
-
* argument is quoted
|
|
67
|
-
*
|
|
66
|
+
* argument is quoted by shellQuoteArg below, which has to satisfy cmd.exe AND
|
|
67
|
+
* the argv parser of whatever it launches — see the note there, and #624 for
|
|
68
|
+
* the half of that rule this file was missing until a real cmd.exe was asked.
|
|
68
69
|
*/
|
|
69
70
|
export function viaCmd(file, args) {
|
|
70
71
|
const line = [file, ...args].map(a => shellQuoteArg(a, "win32")).join(" ");
|
|
@@ -98,17 +99,61 @@ export function viaCmd(file, args) {
|
|
|
98
99
|
*
|
|
99
100
|
* POSIX gets single quotes, inside which NOTHING is special, with the one
|
|
100
101
|
* escape that form has: close the quote, emit a backslash-quote, reopen.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
102
|
+
*
|
|
103
|
+
* Windows gets the rule below — the same one viaCmd applies above, so this file
|
|
104
|
+
* has one Windows quoting rule rather than two. It has to satisfy TWO parsers,
|
|
105
|
+
* and that is the whole of its difficulty, because they disagree about the
|
|
106
|
+
* backslash:
|
|
107
|
+
*
|
|
108
|
+
* cmd.exe reads the line only far enough to find where the command ends. It
|
|
109
|
+
* has no escape for a quote at all — a `"` toggles "inside quotes", and `&`,
|
|
110
|
+
* `|`, `<`, `>`, `^`, `(` and `)` are syntax only while outside — and it does
|
|
111
|
+
* not treat `\` as anything. Then it hands the rest of the line on AS TEXT.
|
|
112
|
+
*
|
|
113
|
+
* The program on the other end splits that text into argv itself, and for
|
|
114
|
+
* node that is the UCRT parser, which DOES read `\` as an escape in front of
|
|
115
|
+
* a quote: 2n backslashes before a `"` are n backslashes and a quote that
|
|
116
|
+
* toggles, 2n+1 are n backslashes and a literal `"`.
|
|
117
|
+
*
|
|
118
|
+
* So an embedded `"` is written `""` rather than `\"` — two toggles is no net
|
|
119
|
+
* change to cmd.exe's idea of where it is, while the child's parser turns the
|
|
120
|
+
* pair back into one quote — and every run of backslashes that ends up in front
|
|
121
|
+
* of a quote, INCLUDING THE CLOSING ONE THIS ADDS, is doubled so the child does
|
|
122
|
+
* not read it as an escape.
|
|
123
|
+
*
|
|
124
|
+
* That last clause is #624 and it was missing. `"` + arg + `"` alone turns a
|
|
125
|
+
* path ending in a separator — `C:\Program Files\nodejs\` — into
|
|
126
|
+
* `"C:\Program Files\nodejs\"`, whose final `\"` is an escaped quote to the
|
|
127
|
+
* child: the quoted region never closes, and the argument swallows every
|
|
128
|
+
* argument after it on the line. `--provider claude` in the hook command is
|
|
129
|
+
* exactly what it swallowed. A backslash immediately before an embedded quote
|
|
130
|
+
* was the quieter half of the same defect — it was eaten as the escape.
|
|
131
|
+
*
|
|
132
|
+
* The one residual left, unchanged: cmd.exe expands `%VAR%` inside quotes too,
|
|
133
|
+
* and a command line has no escape for it. That is narrower than it sounds,
|
|
134
|
+
* since `%foo%` with no variable `foo` is left alone, and it is a limit of the
|
|
135
|
+
* platform rather than of this function. `!VAR!` is the same limit on a machine
|
|
136
|
+
* with delayed expansion turned on, which is not the default for `cmd /c`.
|
|
137
|
+
*
|
|
138
|
+
* no-shell-hook-commands.test.ts runs the output of this through a real cmd.exe
|
|
139
|
+
* on the Windows leg of the matrix and compares what the child RECEIVED against
|
|
140
|
+
* what was intended. Four literals said this function agreed with itself for
|
|
141
|
+
* three releases; they could not say whether it agreed with Windows.
|
|
107
142
|
*/
|
|
108
143
|
export function shellQuoteArg(arg, platform = process.platform) {
|
|
109
144
|
const s = String(arg ?? "");
|
|
110
|
-
if (platform
|
|
111
|
-
|
|
145
|
+
if (platform !== "win32") return `'${s.split("'").join("'\\''")}'`;
|
|
146
|
+
let out = '"';
|
|
147
|
+
let slashes = 0;
|
|
148
|
+
for (const ch of s) {
|
|
149
|
+
if (ch === "\\") { slashes++; continue; }
|
|
150
|
+
if (ch === '"') { out += "\\".repeat(slashes * 2) + '""'; slashes = 0; continue; }
|
|
151
|
+
out += "\\".repeat(slashes) + ch;
|
|
152
|
+
slashes = 0;
|
|
153
|
+
}
|
|
154
|
+
// A run that reaches the end of the argument is in front of the closing quote,
|
|
155
|
+
// which is a quote like any other.
|
|
156
|
+
return `${out}${"\\".repeat(slashes * 2)}"`;
|
|
112
157
|
}
|
|
113
158
|
|
|
114
159
|
/**
|
|
@@ -680,14 +725,17 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
|
|
|
680
725
|
*
|
|
681
726
|
* Returns immediately with a handle:
|
|
682
727
|
* write(text) — into the child's stdin
|
|
683
|
-
* kill() — give up; `done`
|
|
728
|
+
* kill() — give up; `done` settles within killGrace either way
|
|
684
729
|
* onLine(cb) — every complete stdout/stderr line as it arrives
|
|
685
730
|
* done — Promise<{ok, code, killed, timedOut, stdout, stderr}>
|
|
686
731
|
*
|
|
687
|
-
* Never rejects, for the same reason `run` never does.
|
|
688
|
-
*
|
|
732
|
+
* Never rejects, for the same reason `run` never does. And never stays pending:
|
|
733
|
+
* the deadline answers with `code: "ETIMEDOUT", timedOut: true` at the moment
|
|
734
|
+
* it expires rather than waiting on a 'close' a surviving descendant can hold
|
|
735
|
+
* back forever — see the timer below, and #614 for what that cost. Same Windows
|
|
736
|
+
* candidate resolution, since `claude` and `cswap` are `.cmd` shims there.
|
|
689
737
|
*/
|
|
690
|
-
export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 << 10 } = {}) {
|
|
738
|
+
export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 << 10, killGrace = 2_000 } = {}) {
|
|
691
739
|
const tries = candidates(cmd);
|
|
692
740
|
const lineSubs = [];
|
|
693
741
|
let child = null;
|
|
@@ -715,15 +763,39 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
|
|
|
715
763
|
}
|
|
716
764
|
};
|
|
717
765
|
|
|
766
|
+
let graceTimer = null;
|
|
767
|
+
|
|
718
768
|
const finish = (code, err) => {
|
|
719
769
|
if (!settle) return;
|
|
720
770
|
const s = settle; settle = null;
|
|
721
771
|
clearTimeout(timer);
|
|
772
|
+
clearTimeout(graceTimer);
|
|
722
773
|
s({ ok: code === 0 && !err && !timedOut, code: err?.code ?? code ?? -1, killed, timedOut, stdout, stderr });
|
|
723
774
|
};
|
|
724
775
|
|
|
776
|
+
// The deadline states the outcome and only then kills — the order `run` uses
|
|
777
|
+
// forty lines above, for the reason its own header already spells out.
|
|
778
|
+
//
|
|
779
|
+
// This used to set the flag, kill, and leave `done` to the child's 'close'.
|
|
780
|
+
// 'close' waits for the stdio pipes, not merely for the exit, so ONE
|
|
781
|
+
// descendant that outlives the kill holding the inherited stdout keeps it
|
|
782
|
+
// from ever arriving: the child is dead, the promise is pending, and it stays
|
|
783
|
+
// pending for the life of the process. On Windows that is the ordinary shape
|
|
784
|
+
// rather than a corner — a `.cmd` shim runs the real tool as a grandchild
|
|
785
|
+
// under cmd.exe, so a taskkill that cannot run reaches only the wrapper — and
|
|
786
|
+
// on macOS/Linux any cswap subprocess still alive when SIGTERM lands does it.
|
|
787
|
+
//
|
|
788
|
+
// What it cost: both callers await this INSIDE withStoreLock, so the pending
|
|
789
|
+
// promise is the accounts mutex. Every later mutation — login, cancel,
|
|
790
|
+
// import, remove, alias, reorder — queued behind a link that would never
|
|
791
|
+
// settle, the HTTP request was never answered, and spawnLogin's
|
|
792
|
+
// process-on-exit handler leaked with the promise. Reported as #614.
|
|
725
793
|
const timer = setTimeout(() => {
|
|
726
794
|
timedOut = true;
|
|
795
|
+
killed = true;
|
|
796
|
+
// Whatever the child managed to print rides along, the way run()'s tail
|
|
797
|
+
// does: it had not finished, but it is all a caller has to go on.
|
|
798
|
+
finish(-1, { code: "ETIMEDOUT" });
|
|
727
799
|
killTree(child);
|
|
728
800
|
}, timeout);
|
|
729
801
|
timer.unref?.();
|
|
@@ -777,6 +849,12 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
|
|
|
777
849
|
proc.stderr?.on("data", (d) => { if (stale()) return; const t = String(d); stderr = keep(stderr, t); emitLines(t); });
|
|
778
850
|
proc.on("close", (code) => {
|
|
779
851
|
if (stale()) return;
|
|
852
|
+
// Already answered — by the deadline above, or by kill()'s grace below.
|
|
853
|
+
// A late 'close' has nothing left to report, and this is not merely
|
|
854
|
+
// tidiness: the retry underneath re-runs the WHOLE command, and re-running
|
|
855
|
+
// `cswap remove` on behalf of a promise nobody is waiting for any more is
|
|
856
|
+
// exactly the thing that must not happen.
|
|
857
|
+
if (!settle) return;
|
|
780
858
|
// Same cmd.exe case as in `run`: exit 1 with "is not recognized" means
|
|
781
859
|
// this spelling does not exist, not that the tool failed. Restricted to a
|
|
782
860
|
// batch candidate, which is the only kind launched through a shell, and
|
|
@@ -808,10 +886,20 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
|
|
|
808
886
|
try { child?.stdin?.end(); } catch { /* already closed */ }
|
|
809
887
|
},
|
|
810
888
|
/** Stop the run. On Windows that means the tool under the cmd.exe wrapper
|
|
811
|
-
* too — see killTree; a cancelled sign-in used to leave it running.
|
|
889
|
+
* too — see killTree; a cancelled sign-in used to leave it running.
|
|
890
|
+
*
|
|
891
|
+
* `done` settles either way. The kill cannot promise the pipes close —
|
|
892
|
+
* that is the deadline's problem reached from the other side — so if the
|
|
893
|
+
* child's own 'close' has not arrived within killGrace, the handle answers
|
|
894
|
+
* without it. A cancelled sign-in must not be able to wedge the accounts
|
|
895
|
+
* mutex any more than an expired one. */
|
|
812
896
|
kill() {
|
|
813
897
|
killed = true;
|
|
814
898
|
killTree(child);
|
|
899
|
+
if (settle && !graceTimer) {
|
|
900
|
+
graceTimer = setTimeout(() => finish(-1, null), killGrace);
|
|
901
|
+
graceTimer.unref?.();
|
|
902
|
+
}
|
|
815
903
|
},
|
|
816
904
|
onLine(cb) { lineSubs.push(cb); },
|
|
817
905
|
done,
|