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.
@@ -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 dying before the code was accepted is a failure of the login, not
471
- // of the deck; say so rather than leaving the dialog spinning.
472
- child.done.then((r) => {
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 === "awaiting_url" || flow.state === "awaiting_code") {
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 = r.timedOut ? "the sign-in window expired" : failureText(r, "claude auth login") || "sign-in ended without a code";
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
- flow.error = r.timedOut ? "the sign-in window expired" : failureText(r, "claude auth login") || "the code was not accepted";
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 withStoreLock(async () => {
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 firstUseful(out) || `${what} exited ${r?.code}`;
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 async function readCswapConfig() {
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 async function externalAutoRunning() {
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;
@@ -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 here, with embedded quotes doubled, which is the escape
67
- * cmd.exe understands inside a quoted string.
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
- * Windows gets cmd.exe's rule — the same one viaCmd applies above, so this file
102
- * has one Windows quoting rule rather than two with the same single residual
103
- * exec.mjs already documents: cmd.exe expands `%VAR%` inside quotes too, and a
104
- * command line has no escape for it. That is narrower than it sounds, since
105
- * `%foo%` with no variable `foo` is left alone, and it is a limit of the
106
- * platform rather than of this function.
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 === "win32") return `"${s.replace(/"/g, '""')}"`;
111
- return `'${s.split("'").join("'\\''")}'`;
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` still settles
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. Same Windows candidate
688
- * resolution, since `claude` and `cswap` are `.cmd` shims there.
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,