ccdeck 3.29.10 → 3.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +28 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -26,7 +26,25 @@
26
26
  // launched by its full path where one can be found; see shimPath and
27
27
  // candidateSpec.
28
28
  import { execFile, spawn } from "node:child_process";
29
- import { existsSync } from "node:fs";
29
+ // Which spelling of a command to try, whether it goes through cmd.exe, and the
30
+ // exact file, argv and options spawn is handed for it — all of it decided
31
+ // before anything is spawned, and all of it platform-parameterised so the
32
+ // Windows answer can be tested anywhere.
33
+ import { candidateSpec, candidates, isBatch, rememberSpelling } from "./exec-spec.mjs";
34
+ export {
35
+ candidateSpec, candidates, isBatch, pathLookup, shellQuoteArg, shimPath, spawnSpec, viaCmd,
36
+ } from "./exec-spec.mjs";
37
+ // Reading cmd.exe's "is not recognized" — which decides whether the candidate
38
+ // loop moves on and whether the answer is ENOENT — is a question about text and
39
+ // has a module of its own. Re-exported, so its callers need not know that.
40
+ import { looksMissing } from "./exec-not-found.mjs";
41
+ export { looksMissing, notFoundExit } from "./exec-not-found.mjs";
42
+ // Stopping a child and everything under it, and the set of children still
43
+ // running that shutdown reaps. `run` and `runInteractive` wrap their children
44
+ // in watchChild; `runDetached` deliberately does not, for the reason the note
45
+ // on `live` gives.
46
+ import { killTree, watchChild } from "./exec-children.mjs";
47
+ export { killLiveChildren, killTree, liveChildPids } from "./exec-children.mjs";
30
48
 
31
49
  // WINDOWS SEARCHES THE WORKING DIRECTORY FIRST, and this turns that off.
32
50
  //
@@ -51,432 +69,6 @@ if (process.platform === "win32" && !("NoDefaultCurrentDirectoryInExePath" in pr
51
69
  process.env.NoDefaultCurrentDirectoryInExePath = "1";
52
70
  }
53
71
 
54
-
55
- // Extensions Windows will execute, most specific first. `.com` is omitted —
56
- // nothing ships one, and every extra candidate costs a failed spawn.
57
- const WIN_EXTS = [".exe", ".cmd", ".bat", ""];
58
-
59
- // Which spelling worked, per command name. A failed spawn is cheap but not
60
- // free, and these run on a poll.
61
- const resolved = new Map();
62
-
63
- /**
64
- * The spellings to try for `cmd`, best first.
65
- *
66
- * Exported with the platform as a parameter for the reason everything else in
67
- * this file is: the Windows answer decides which candidate becomes a batch one,
68
- * and a batch candidate is the only kind #457 touches — so a test that wants to
69
- * follow a caller's bare name all the way to the command line cmd.exe receives
70
- * has to be able to ask for the Windows list from a machine that is not Windows.
71
- */
72
- export function candidates(cmd, platform = process.platform) {
73
- if (platform !== "win32") return [cmd];
74
- // An explicit extension is respected as given.
75
- if (/\.[a-z]+$/i.test(cmd)) return [cmd];
76
- const known = resolved.get(cmd);
77
- return known ? [known] : WIN_EXTS.map(ext => cmd + ext);
78
- }
79
-
80
- /** Exported for tests: the platform is a parameter so both can be checked. */
81
- export const isBatch = (file, platform = process.platform) =>
82
- platform === "win32" && /\.(cmd|bat)$/i.test(file);
83
-
84
- /**
85
- * Rewrite a batch-file invocation as a cmd.exe one.
86
- *
87
- * Mirrors what Node does internally for `shell: true` on Windows — comspec,
88
- * /d /s /c, the whole command line as a single quoted argument, and
89
- * windowsVerbatimArguments so Node does not quote it a second time. Each
90
- * argument is quoted by shellQuoteArg below, which has to satisfy cmd.exe AND
91
- * the argv parser of whatever it launches — see the note there, and #624 for
92
- * the half of that rule this file was missing until a real cmd.exe was asked.
93
- */
94
- export function viaCmd(file, args) {
95
- const line = [file, ...args].map(a => shellQuoteArg(a, "win32")).join(" ");
96
- return {
97
- file: process.env.comspec || process.env.ComSpec || "cmd.exe",
98
- args: ["/d", "/s", "/c", `"${line}"`],
99
- opts: { windowsVerbatimArguments: true },
100
- };
101
- }
102
-
103
- /**
104
- * Quote ONE argument into a command line a shell will parse.
105
- *
106
- * Everything else in this module exists to avoid needing this: an argument
107
- * vector is handed to spawn untouched and nothing in it is ever read as syntax.
108
- * Two callers cannot take that route, and they are the reason this is exported.
109
- * A Claude Code hook is registered as `{type:"command", command:"<string>"}` —
110
- * the host CLI runs that string THROUGH A SHELL on every tool call, and the
111
- * format has no argv form to emit instead. So the string has to be built here,
112
- * correctly, once.
113
- *
114
- * The inputs are not user input in the web sense, but they are not constants
115
- * either: the hook path is built from $CLAUDE_CONFIG_DIR and homedir(), and the
116
- * node path from process.execPath. Wrapping those in double quotes — what this
117
- * used to do — is not escaping on POSIX at all: `$(…)`, a backtick and `\` are
118
- * all still live inside them, so a config dir named `/tmp/a$(id)b` became shell
119
- * code written into the user's settings.json and executed on every hook fire.
120
- * A bare `$` in a path is the same bug wearing a duller hat — `$HOME` expands to
121
- * nothing, the hook path silently becomes wrong, and hooks stop firing with no
122
- * error anywhere.
123
- *
124
- * POSIX gets single quotes, inside which NOTHING is special, with the one
125
- * escape that form has: close the quote, emit a backslash-quote, reopen.
126
- *
127
- * Windows gets the rule below — the same one viaCmd applies above, so this file
128
- * has one Windows quoting rule rather than two. It has to satisfy TWO parsers,
129
- * and that is the whole of its difficulty, because they disagree about the
130
- * backslash:
131
- *
132
- * cmd.exe reads the line only far enough to find where the command ends. It
133
- * has no escape for a quote at all — a `"` toggles "inside quotes", and `&`,
134
- * `|`, `<`, `>`, `^`, `(` and `)` are syntax only while outside — and it does
135
- * not treat `\` as anything. Then it hands the rest of the line on AS TEXT.
136
- *
137
- * The program on the other end splits that text into argv itself, and for
138
- * node that is the UCRT parser, which DOES read `\` as an escape in front of
139
- * a quote: 2n backslashes before a `"` are n backslashes and a quote that
140
- * toggles, 2n+1 are n backslashes and a literal `"`.
141
- *
142
- * So an embedded `"` is written `""` rather than `\"` — two toggles is no net
143
- * change to cmd.exe's idea of where it is, while the child's parser turns the
144
- * pair back into one quote — and every run of backslashes that ends up in front
145
- * of a quote, INCLUDING THE CLOSING ONE THIS ADDS, is doubled so the child does
146
- * not read it as an escape.
147
- *
148
- * That last clause is #624 and it was missing. `"` + arg + `"` alone turns a
149
- * path ending in a separator — `C:\Program Files\nodejs\` — into
150
- * `"C:\Program Files\nodejs\"`, whose final `\"` is an escaped quote to the
151
- * child: the quoted region never closes, and the argument swallows every
152
- * argument after it on the line. `--provider claude` in the hook command is
153
- * exactly what it swallowed. A backslash immediately before an embedded quote
154
- * was the quieter half of the same defect — it was eaten as the escape.
155
- *
156
- * The one residual left, unchanged: cmd.exe expands `%VAR%` inside quotes too,
157
- * and a command line has no escape for it. That is narrower than it sounds,
158
- * since `%foo%` with no variable `foo` is left alone, and it is a limit of the
159
- * platform rather than of this function. `!VAR!` is the same limit on a machine
160
- * with delayed expansion turned on, which is not the default for `cmd /c`.
161
- *
162
- * no-shell-hook-commands.test.ts runs the output of this through a real cmd.exe
163
- * on the Windows leg of the matrix and compares what the child RECEIVED against
164
- * what was intended. Four literals said this function agreed with itself for
165
- * three releases; they could not say whether it agreed with Windows.
166
- */
167
- export function shellQuoteArg(arg, platform = process.platform) {
168
- const s = String(arg ?? "");
169
- if (platform !== "win32") return `'${s.split("'").join("'\\''")}'`;
170
- let out = '"';
171
- let slashes = 0;
172
- for (const ch of s) {
173
- if (ch === "\\") { slashes++; continue; }
174
- if (ch === '"') { out += "\\".repeat(slashes * 2) + '""'; slashes = 0; continue; }
175
- out += "\\".repeat(slashes) + ch;
176
- slashes = 0;
177
- }
178
- // A run that reaches the end of the argument is in front of the closing quote,
179
- // which is a quote like any other.
180
- return `${out}${"\\".repeat(slashes * 2)}"`;
181
- }
182
-
183
- /**
184
- * The absolute path of a Windows command shim, or null when nothing answers to
185
- * that name.
186
- *
187
- * Why a bare name is not good enough, which is the whole of #456. npm's `.cmd`
188
- * shims locate every file they need relative to THEMSELVES:
189
- *
190
- * SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
191
- * SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
192
- *
193
- * `%~dp0` is the drive and path of `%0`, and `%0` is the command token cmd.exe
194
- * was given. Handed `cmd.exe /d /s /c ""npm.cmd" "install" …`, that token
195
- * carries no directory of its own, so `%~dp0` came out as the deck's WORKING
196
- * DIRECTORY instead of the shim's. Reported from Windows 10 with the deck
197
- * started from `C:\Users\vceban`:
198
- *
199
- * Error: Cannot find module 'C:\Users\vceban\node_modules\npm\bin\npm-prefix.js'
200
- * Error: Cannot find module 'C:\Users\vceban\node_modules\npm\bin\npx-cli.js'
201
- *
202
- * — two stacks, one defect, and `C:\Users\vceban` is the cwd rather than
203
- * anything to do with npm: `where npx` on that machine printed
204
- * `C:\Program Files\nodejs\npx.cmd` and `npx ccdeck` ran perfectly from a
205
- * prompt. It is ours, introduced when #362 replaced `shell: true` with viaCmd:
206
- * the quoting was the fix and the bare name was the cost, and it broke the
207
- * managed install and the npx fallback in the same stroke, which is why every
208
- * diagnosis of one half kept half-fitting.
209
- *
210
- * Node's own directory is tried before PATH because node and npm ship together
211
- * and that is where the shims are — the same preference npxCliCandidates in
212
- * npx.mjs states for npm's CLI scripts, so the repo has one rule rather than
213
- * two. PATH is then walked in its own order, which is what cmd.exe would have
214
- * done, minus the current directory it searches first: a deck that resolves its
215
- * npm out of whatever folder it happens to be sitting in is the bug above
216
- * wearing a hat.
217
- *
218
- * The path arithmetic is spelled out rather than done through `node:path` for
219
- * the reason npxCliCandidates gives: `path` is the platform running the SUITE,
220
- * so a Windows layout checked from macOS would come back with forward slashes.
221
- * `execPath`, `pathEnv` and `exists` are injected for the same reason — the
222
- * Windows answer has to be checkable from an OS that cannot run it.
223
- *
224
- * The walk itself now lives in `pathLookup` below, because #433 needed the same
225
- * one for a tool that is not a shim; this is that walk with the two answers a
226
- * shim needs — Windows, and node's own directory first.
227
- */
228
- export const shimPath = (name, deps) =>
229
- pathLookup(name, "win32", { besideNode: true, ...deps });
230
-
231
- /**
232
- * Where `name` actually lives on PATH, as an absolute path, or null when
233
- * nothing on PATH answers to it.
234
- *
235
- * This is the general form of shimPath above, and it exists because #433 asked
236
- * for a third way to reach ccusage: the copy a user installed themselves. The
237
- * deck's own error text has told people to "put ccusage on PATH yourself" since
238
- * before there was anything that looked, so the choice was to either look or
239
- * stop saying it — see getRunner in ccusage.mjs for which way that went.
240
- *
241
- * Writing it as ONE walk rather than a second one is the point. A PATH search
242
- * on Windows is not a PATH search plus a note: `ccusage` there is `ccusage.cmd`
243
- * or `ccusage.exe` and never the bare name, because PATHEXT is a shell's job
244
- * and spawn is not a shell — which is the whole reason `candidates` exists, so
245
- * that is what supplies the spellings here. And a `.cmd` found this way is
246
- * returned as a FULL PATH, which is what keeps #456 fixed: launched by its bare
247
- * name through cmd.exe, a shim computes `%~dp0` from the deck's working
248
- * directory and goes hunting for its payload there.
249
- *
250
- * `besideNode` is the one thing a shim wants and a tool does not. npm ships
251
- * beside node, so looking there first is right for `npm.cmd`; for anything else
252
- * it would quietly overrule the order the user put their own PATH in, which is
253
- * the one statement of preference they actually made.
254
- *
255
- * The check is existence, not executability. That is what `run`'s candidate
256
- * loop effectively asks too — it spawns and moves on if the spawn fails — and
257
- * an executable bit is not a thing Windows has. The residue is a directory on
258
- * PATH that happens to be named after the tool; it would be resolved here and
259
- * fail on spawn, with the failure naming the path, which is a better place to
260
- * find out than a silent miss.
261
- */
262
- export function pathLookup(name, platform = process.platform, {
263
- execPath = process.execPath,
264
- pathEnv = process.env.PATH ?? process.env.Path ?? "",
265
- exists = existsSync,
266
- besideNode = false,
267
- } = {}) {
268
- // A name that already carries a directory needs no lookup, and re-rooting it
269
- // would be a way to run something else entirely.
270
- if (typeof name !== "string" || !name || /[\\/]/.test(name)) return null;
271
- const win = platform === "win32";
272
- const sep = win ? "\\" : "/";
273
- const dirs = [];
274
- if (besideNode) {
275
- const beside = String(execPath ?? "").split(/[\\/]/).slice(0, -1).join(sep);
276
- if (beside) dirs.push(beside);
277
- }
278
- // `;` on Windows, `:` everywhere else. Splitting on the wrong one is not a
279
- // near miss: a POSIX PATH read with `;` is one enormous directory that
280
- // exists nowhere, so every lookup would answer null and the feature would
281
- // look like it had never been written.
282
- for (const raw of String(pathEnv ?? "").split(win ? ";" : ":")) {
283
- // A PATH entry may be quoted, and may end in a separator; neither is part
284
- // of the directory, and both would produce a path nothing exists at.
285
- const dir = raw.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "");
286
- if (dir) dirs.push(dir);
287
- }
288
- // On POSIX this is `[name]`, so the inner loop runs once and the cost is one
289
- // stat per directory, exactly as before.
290
- const spellings = candidates(name, platform);
291
- for (const dir of dirs) {
292
- for (const spelling of spellings) {
293
- const full = `${dir}${sep}${spelling}`;
294
- try {
295
- if (exists(full)) return full;
296
- } catch {
297
- // An entry that cannot even be stat'ed — a disconnected network drive
298
- // is the usual one — is a miss, not a reason to stop looking.
299
- }
300
- }
301
- }
302
- return null;
303
- }
304
-
305
- /**
306
- * What to hand `spawn`/`execFile` for `file` and `args` on this platform, with
307
- * the argument vector intact and no shell.
308
- *
309
- * The alternative every caller reaches for first — `shell: true`, because a
310
- * .cmd cannot be spawned any other way — is the one thing that must not be
311
- * used: Node joins the array into a command line with a single space and no
312
- * per-argument quoting, so `--workspace C:\Users\John Smith\proj` arrives as
313
- * two arguments and an `&` in a path ends the command early. Batch files go
314
- * through cmd.exe with each argument quoted; everything else is spawned as
315
- * given. The platform is a parameter so both branches can be tested.
316
- *
317
- * The one thing quoting cannot cover: cmd.exe expands `%VAR%` inside quotes
318
- * too, and a command line has no escape for it. Every other metacharacter —
319
- * `&`, `|`, `>`, `^`, `(` — is inert once quoted.
320
- */
321
- export const spawnSpec = (file, args, platform = process.platform) =>
322
- isBatch(file, platform) ? viaCmd(file, args) : { file, args, opts: {} };
323
-
324
- /**
325
- * The same thing for ONE CANDIDATE SPELLING out of the list above — which on
326
- * Windows means deciding the NAME the shim is launched under before deciding
327
- * how, because those are not the same question.
328
- *
329
- * `spawnSpec` answers "how". This answers what #457 put in front of it. A batch
330
- * candidate runs THROUGH cmd.exe (see viaCmd), and a `.cmd` shim locates its own
331
- * payload relative to `%~dp0` — the drive and path of the command token cmd.exe
332
- * was handed. A bare `claude.cmd` carries no directory at all, so `%~dp0` came
333
- * out as the deck's WORKING DIRECTORY and the shim went hunting for its
334
- * JavaScript under whatever folder the deck happened to be started from. #456
335
- * proved exactly that for ccusage's `npm.cmd` and `npx.cmd`; it left the three
336
- * helpers below alone, and they carry the shims the panels depend on — the
337
- * `claude.cmd` behind the quota poll and the sign-in, and whatever `.cmd` a
338
- * Python installer left for cswap. Same defect, same machine, wider blast
339
- * radius.
340
- *
341
- * `?? raw` is the whole safety story and is not optional: when shimPath can see
342
- * no layout it answers null, and the candidate stays the bare name today's code
343
- * already uses, so nothing that works now can start failing. It is the same
344
- * `?? name` ccusage.mjs and npx.mjs spell, so the repo has one rule rather than
345
- * three. shimPath also refuses any name that already carries a directory, which
346
- * is what keeps a caller's own absolute candidate — quotaClaudeBin's
347
- * `%APPDATA%\npm\claude.cmd`, cswapCandidates' `~/.local/bin/cswap.exe` — from
348
- * being re-rooted somewhere else entirely.
349
- *
350
- * `launch` comes back beside the spec because looksMissing has to be told the
351
- * spelling cmd.exe was ACTUALLY GIVEN rather than the one the loop started
352
- * from; its own header explains what breaks otherwise, and that coupling is the
353
- * reason #456 stopped short of doing this.
354
- *
355
- * On POSIX `isBatch` is false, so no lookup happens, no filesystem is touched,
356
- * `launch` is `raw`, and the spec is the object spawnSpec always returned —
357
- * byte-identical, which is the point.
358
- */
359
- export function candidateSpec(raw, args, platform = process.platform, deps) {
360
- const launch = isBatch(raw, platform) ? (shimPath(raw, deps) ?? raw) : raw;
361
- return { ...spawnSpec(launch, args, platform), launch };
362
- }
363
-
364
- /**
365
- * Stop a child AND everything it started.
366
- *
367
- * On POSIX the child is the tool, so a signal to it is the whole job and this
368
- * is exactly the `child.kill()` it replaces. On Windows a .cmd or .bat runs
369
- * THROUGH cmd.exe (see viaCmd), so the tool — node, for the claude and npm
370
- * shims — is a grandchild, and a kill is one TerminateProcess against the
371
- * wrapper. Windows terminates no descendants and libuv's job object lets them
372
- * break away, so the wrapper vanished and the work carried on: every cancelled
373
- * or expired `claude auth login` left a node.exe blocked forever on the stdin
374
- * pipe this process holds, and because that node.exe kept the inherited stdout
375
- * handle open, the wrapper's 'close' never arrived either — the run that was
376
- * reported dead never settled.
377
- *
378
- * `taskkill /T` is the descendant walk Windows does have, and `/F` is what
379
- * stops a console app that is not pumping its message queue. Taking it from
380
- * System32 rather than from PATH matters here: PATH is the user's, and this is
381
- * the program we hand a pid to kill. If it cannot run at all, the plain kill
382
- * still happens, which is no worse than before.
383
- *
384
- * A process group would be the tidier answer and is the wrong one on Windows:
385
- * `detached` there only means CREATE_NEW_PROCESS_GROUP, and Node cannot signal
386
- * a group — `process.kill(-pid)` is POSIX-only.
387
- */
388
- export function killTree(child, signal) {
389
- const plain = () => { try { child?.kill(signal); } catch { /* already gone */ } };
390
- if (process.platform !== "win32" || !child?.pid) return plain();
391
- try {
392
- const root = process.env.SystemRoot || process.env.systemroot;
393
- const exe = root ? `${root}\\System32\\taskkill.exe` : "taskkill";
394
- const killer = spawn(exe, ["/pid", String(child.pid), "/T", "/F"], {
395
- stdio: "ignore", windowsHide: true,
396
- });
397
- killer.on("error", plain);
398
- killer.on("exit", (code) => { if (code !== 0) plain(); });
399
- killer.unref?.();
400
- } catch {
401
- plain();
402
- }
403
- }
404
-
405
- /**
406
- * Every child this module started and has not yet seen the end of.
407
- *
408
- * A deadline is not a promise unless something is alive to enforce it, and the
409
- * deadlines in this file all live in the parent: `run` states the outcome on a
410
- * timer and only then kills, `runInteractive` does the same. Kill the parent
411
- * and the timer dies with it, so the very case a deadline exists for — a tool
412
- * that has hung — is the one case where nothing is left to stop it.
413
- *
414
- * Observed (#1012). A sandboxed deck, a quota poll in flight, SIGINT to the
415
- * deck at 05:26:22.706. The deck was gone within 200ms; its `claude --print
416
- * /usage` child, spawned at 05:26:18.078 under a 15-second deadline, was still
417
- * running at 05:26:35.204 — reparented to init, past the deadline the dead
418
- * parent would have enforced, and with nothing anywhere that would ever kill
419
- * it. A whole Claude Code process, hundreds of MB resident, orphaned by a
420
- * Ctrl+C.
421
- *
422
- * REMOVED ON 'close', NOT ON 'exit', and the difference is Windows'. A batch
423
- * candidate runs THROUGH cmd.exe and the tool is a grandchild (see viaCmd and
424
- * killTree): the wrapper can exit while the tool underneath goes on holding
425
- * the inherited stdio, which is exactly the shape killTree's `taskkill /T`
426
- * exists for. 'exit' there means "the wrapper is gone" and 'close' means "and
427
- * so is everything it started" — forgetting on the first would drop a tree
428
- * that is still running and still reachable. 'error' is the third way a child
429
- * ends, and the only one after which 'close' may never come at all.
430
- *
431
- * `runDetached` is deliberately absent. It is the one function here named for
432
- * outliving its call — a sound, or a collection whose result lands in a file
433
- * the next poll reads — it holds no deadline, and it unrefs its child on
434
- * purpose. Killing those on the way out would be this set overreaching.
435
- */
436
- const live = new Set();
437
-
438
- /** Track one child until it ends. Returns it, so it can wrap a spawn inline. */
439
- function watchChild(cp) {
440
- if (!cp) return cp;
441
- live.add(cp);
442
- const forget = () => { live.delete(cp); };
443
- cp.once?.("close", forget);
444
- cp.once?.("error", forget);
445
- return cp;
446
- }
447
-
448
- /**
449
- * The pids of the children this deck still has running.
450
- *
451
- * Exported for the test below this, and because it is the honest answer to
452
- * "what is this deck still doing" that a future `--status` wants. It may
453
- * over-report by one in the Windows case above — a wrapper whose 'close' never
454
- * arrives stays here — which is the right direction to be wrong in: that entry
455
- * names a process that really is still running.
456
- */
457
- export const liveChildPids = () => [...live].map(c => c?.pid).filter(Boolean);
458
-
459
- /**
460
- * Stop every child this module started, and everything they started.
461
- *
462
- * Called from shutdown(), where the alternative is the orphan above. It does
463
- * not WAIT for the corpses, on purpose: a signal is delivered synchronously on
464
- * POSIX, and on Windows killTree's taskkill is a process of its own that
465
- * CreateProcess has already started by the time spawn() returns and that
466
- * outlives this one. Waiting would buy nothing and would spend the ~200ms exit
467
- * that is the rest of shutdown's good behaviour.
468
- *
469
- * The set is cleared first so a 'close' arriving mid-loop cannot mutate what
470
- * is being iterated, and so a second call — shutdown can be reached twice —
471
- * has nothing left to do.
472
- */
473
- export function killLiveChildren(signal) {
474
- const doomed = [...live];
475
- live.clear();
476
- for (const cp of doomed) killTree(cp, signal);
477
- return doomed.length;
478
- }
479
-
480
72
  // Reasons to try the next candidate spelling rather than give up. EINVAL and
481
73
  // UNKNOWN show up on Windows for a file that exists but cannot be executed the
482
74
  // way it was asked for; both mean "not this one", not "no such tool".
@@ -484,206 +76,6 @@ export const tryNext = (err) =>
484
76
  Boolean(err) && (err.code === "ENOENT" || err.code === "EACCES" ||
485
77
  err.code === "EINVAL" || err.code === "UNKNOWN");
486
78
 
487
- // The three lines an ENGLISH cmd.exe prints when it cannot find what it was
488
- // asked to run, anchored to a whole line each. First the two-line pair for a
489
- // bare name, then the one it uses when a directory in an explicit path does not
490
- // exist.
491
- //
492
- // These are one signal out of three rather than the whole answer — see
493
- // looksMissing. Windows ships cmd.exe in every language it ships in, and these
494
- // sentences are translated with it.
495
- const CMD_UNKNOWN = /^'(.+)' is not recognized as an internal or external command,?$/i;
496
- const CMD_UNKNOWN_TAIL = /^operable program or batch file\.?$/i;
497
- const CMD_NO_PATH = /^the system cannot find the (?:path|file) specified\.?$/i;
498
-
499
- // cmd.exe's own errorlevel for a command token it could not resolve — the one
500
- // signal here that is not a human sentence, and therefore the only one that is
501
- // the same on a German install as on an English one. It is the number every CI
502
- // log in the world prints beside "is not recognized".
503
- //
504
- // Not every path reports it: some Windows builds answer a bare `cmd /c missing`
505
- // with a plain 1 instead, which is why this is a sufficient signal and never a
506
- // necessary one. A number this large cannot arrive from POSIX at all — a process
507
- // exit status there is masked to 0-255 before Node ever sees it — so nothing
508
- // outside cmd.exe can reach it by accident.
509
- const CMD_NOT_FOUND_EXIT = 9009;
510
-
511
- /** True when an exit status is cmd.exe saying "no such command", in any locale. */
512
- export const notFoundExit = (code) => Number(code) === CMD_NOT_FOUND_EXIT;
513
-
514
- // Every run of text this line wrapped in quotes. cmd.exe quotes the command
515
- // token it could not find in EVERY locale, and the quote character is the only
516
- // part of the message that is not a translation: `'…'` in English and French,
517
- // `"…"` in German, Spanish, Italian, Portuguese, Polish and Russian.
518
- const quotedRuns = (line) =>
519
- [...String(line).matchAll(/'([^']+)'|"([^"]+)"/g)].map(m => m[1] ?? m[2]);
520
-
521
- // cmd.exe echoes the command token exactly as it was given, so an exact match
522
- // is what we expect; the comparison is only case- and quote-insensitive because
523
- // Windows paths are.
524
- const sameCommand = (quoted, name) => {
525
- const norm = (s) => String(s).trim().replace(/^"+|"+$/g, "").toLowerCase();
526
- return norm(quoted) === norm(name);
527
- };
528
-
529
- /**
530
- * cmd.exe's way of saying ENOENT — and only cmd.exe's.
531
- *
532
- * A .cmd or .bat candidate is launched THROUGH cmd.exe, and cmd.exe exists — so
533
- * a missing tool is not a spawn error at all. It is a healthy shell exiting 1
534
- * after printing two lines:
535
- *
536
- * 'cswap' is not recognized as an internal or external command,
537
- * operable program or batch file.
538
- *
539
- * Read as a real failure, that stops the candidate loop early AND puts the
540
- * second line — on its own, meaningless — in front of the user. Reported from
541
- * Windows on 2026-08-14: the accounts panel said only "operable program or
542
- * batch file." when sharing an account.
543
- *
544
- * The fussiness is about the other direction, which is worse. "The system
545
- * cannot find the file specified" is Windows' generic text for ENOENT, and it
546
- * appears INSIDE the output of tools that ran perfectly well — cswap is a
547
- * Python CLI, and a FileNotFoundError traceback ends in that exact sentence.
548
- * Believed there, it threw the real error away, told the user the tool was not
549
- * on PATH when PATH was fine, and — the dangerous half — sent the candidate
550
- * loop on to re-run the entire command, which for `cswap remove 3` means asking
551
- * to delete an account a second time.
552
- *
553
- * So the output has to be cmd.exe's message and nothing else: cmd.exe prints it
554
- * INSTEAD of running anything, so any other line, or any prefix on the line,
555
- * means something ran and this is its report. And when cmd.exe names the
556
- * command it could not find, that name must be the candidate we asked for — a
557
- * tool that shells out itself can forward the message about some other command.
558
- *
559
- * `name` is the spelling cmd.exe was ACTUALLY GIVEN — which since #457 is the
560
- * shim's absolute path whenever shimPath found one, not the bare candidate the
561
- * loop started from. That distinction is the coupling #456 named as its reason
562
- * for stopping short, and it is a one-way trap rather than a detail: cmd.exe
563
- * echoes the command token back exactly as it received it, so a check against
564
- * the bare name stops matching the instant the token becomes a path. What that
565
- * costs is not a cosmetic mismatch — it is the honesty of the whole answer. A
566
- * shim that shimPath saw and that is gone by the time cmd.exe looks for it (a
567
- * stale memo, an uninstall mid-session, a network drive that dropped, a roaming
568
- * profile still syncing) would come back as an ordinary exit 1 whose stderr is
569
- * cmd.exe's two-line "is not recognized / operable program or batch file."
570
- * instead of the `code: "ENOENT"` every panel keys its "not installed" message
571
- * off. The user would be told their CLI failed, and shown half a sentence about
572
- * batch files, when the truthful answer is that it is not there.
573
- *
574
- * Feeding it the launch spelling keeps the comparison EXACT, which is what the
575
- * paragraph above is protecting: matching loosely — on the basename, say —
576
- * would let a tool that shells out itself have its own child's "is not
577
- * recognized" read as the tool's absence, re-running a command that may be
578
- * `cswap remove 3`. So the rule is not "compare less", it is "compare against
579
- * what was actually asked for".
580
- *
581
- * Callers that only have the text (failureText) omit it and get the shape rules
582
- * alone.
583
- *
584
- * ── AND NOT ONLY IN ENGLISH (#552) ──────────────────────────────────────────
585
- *
586
- * Everything above was written against three English sentences, and cmd.exe is
587
- * translated. On a German install with cswap genuinely absent it prints
588
- *
589
- * Der Befehl "cswap" ist entweder falsch geschrieben oder konnte nicht
590
- * gefunden werden.
591
- *
592
- * — so this answered false, `run` resolved `{ ok: false, code: 1 }`,
593
- * claude-accounts.mjs picked `reason: "switch_failed"` over `"no_cswap"` and the
594
- * panel's install affordance never appeared. failureText then fell through to
595
- * firstUseful, which puts the LAST line of a localized sentence on screen by
596
- * itself: #457's symptom reproduced for every non-English locale. It also
597
- * stopped the candidate loop early, so a `.bat` installed after a missing `.cmd`
598
- * was never reached.
599
- *
600
- * Adding the German sentence, and then the French and the Japanese ones, is not
601
- * a fix — it is the same defect with a longer list. So two signals that are not
602
- * sentences carry the answer instead, and the English text is what remains when
603
- * neither is available:
604
- *
605
- * 1. THE EXIT STATUS. `exitCode` 9009 is cmd.exe's own errorlevel for a
606
- * command token it could not resolve, identical in every language. See
607
- * CMD_NOT_FOUND_EXIT for why it is not required, and the paragraph below
608
- * for the two cases where it is not believed either. It is subject to the
609
- * same "cmd.exe printed nothing else" cap as rule 2, because a status is
610
- * forwarded as easily as a sentence is.
611
- *
612
- * 2. THE SHAPE. cmd.exe quotes the command it could not find, in every locale,
613
- * and prints that INSTEAD of running anything — so the whole output is at
614
- * most the two lines of one wrapped sentence. Text of at most two lines
615
- * whose FIRST line quotes exactly the spelling we launched is cmd.exe's
616
- * verdict about our command whatever the words around it say.
617
- *
618
- * Rule 2 needs `name`, and refuses without it. That is the same principle the
619
- * paragraphs above argue for and not a limitation bolted on: `Error: "account-9"
620
- * does not exist` is one line with a quoted token in it, and read as an absence
621
- * it would send the candidate loop back round to re-run `cswap remove 3`. What
622
- * makes the rule safe is that the quoted token has to be the exact spelling
623
- * cmd.exe was handed — `cswap.cmd`, or the absolute path shimPath found — which
624
- * is a string the tool underneath has no reason to print. The two-line cap is
625
- * the other half: a Python traceback ending in "The system cannot find the file
626
- * specified" is four lines and can never qualify.
627
- *
628
- * THE SAME TRAP THE ENGLISH RULE ALREADY AVOIDS, NOW FOR THE EXIT STATUS. A
629
- * `.cmd` shim is itself a batch file, so a shim that EXISTS and whose payload
630
- * interpreter does not — a scoop or npm-style `cswap.cmd` in front of a python
631
- * that was uninstalled — has cmd.exe print "is not recognized" about PYTHON and
632
- * hands the shim's caller that same 9009. Believed on its own, the deck would
633
- * call the tool absent and re-run the command under the next spelling. So a text
634
- * that positively names a command OTHER than ours vetoes every rule here,
635
- * including the status: the check that made #457 safe, applied one level up.
636
- *
637
- * What is deliberately still missed: a LOCALIZED "the system cannot find the
638
- * path specified", which carries no quoted token and no structure to key off.
639
- * That case only arises when shimPath found a shim that then vanished, and it
640
- * fails in the safe direction — an honest exit 1 rather than a wrong ENOENT.
641
- */
642
- export function looksMissing(text, name = "", exitCode = null) {
643
- const lines = String(text ?? "").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
644
- // Whatever cmd.exe named, in whatever language it said the rest — the quoting
645
- // is the part that is not a translation.
646
- const named = lines.length ? quotedRuns(lines[0]) : [];
647
- const namesUs = named.some(q => sameCommand(q, name));
648
-
649
- // The veto, before anything is believed: a message about somebody else's
650
- // command is not evidence about ours, and neither is the status that came
651
- // with it. See the header — a `.cmd` shim in front of a missing interpreter
652
- // forwards both.
653
- if (name && named.length > 0 && !namesUs) return false;
654
-
655
- // cmd.exe says this INSTEAD of running anything, so anything longer than one
656
- // wrapped sentence came from something that DID run — and that outranks both
657
- // signals below. A shim can forward its child's 9009 after printing pages of
658
- // its own; a Python traceback is four lines and one of them quotes the very
659
- // shim we launched.
660
- const saidNothingElse = lines.length <= 2;
661
-
662
- // The signal that is not a sentence. It does not need to READ the text, which
663
- // is the whole reason it exists: on a non-English install there may be nothing
664
- // in the text this can read.
665
- if (saidNothingElse && notFoundExit(exitCode)) return true;
666
- if (!lines.length) return false;
667
-
668
- // The English shapes, whole-line anchored, exactly as before.
669
- let english = true;
670
- for (const line of lines) {
671
- const unknown = CMD_UNKNOWN.exec(line);
672
- if (unknown) {
673
- if (name && !sameCommand(unknown[1], name)) return false;
674
- continue;
675
- }
676
- if (CMD_UNKNOWN_TAIL.test(line) || CMD_NO_PATH.test(line)) continue;
677
- english = false;
678
- break;
679
- }
680
- if (english) return true;
681
-
682
- // Otherwise: cmd.exe in some other language, recognised by its shape and by
683
- // the one word in it that is ours.
684
- return Boolean(name) && saidNothingElse && namesUs;
685
- }
686
-
687
79
  // How much of a hung child's output the deadline keeps. The full buffers belong
688
80
  // to execFile's callback, which a timed-out run never waits for, and the tail is
689
81
  // where a tool puts the line that explains itself.
@@ -756,7 +148,7 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
756
148
  // whole life — an upgrade that moves the shim would then fail forever
757
149
  // where today it simply gets found again. Re-running the lookup per
758
150
  // attempt costs a handful of stats against a process spawn.
759
- if (!err) resolved.set(cmd, raw);
151
+ if (!err) rememberSpelling(cmd, raw);
760
152
  resolve({
761
153
  ok: !err,
762
154
  // A tool cmd.exe could not find is missing, not "exited 1" — callers
@@ -772,7 +164,7 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
772
164
  try {
773
165
  // watchChild: this run's deadline lives in the timer below, in THIS
774
166
  // process, so a shutdown that does not reap it leaves the child with
775
- // no deadline at all. See `live` above, and #1012.
167
+ // no deadline at all. See `live` in exec-children.mjs, and #1012.
776
168
  const cp = watchChild(execFile(file, argv,
777
169
  { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done));
778
170
  // Give the child EOF on stdin straight away, which is what this
@@ -847,30 +239,13 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
847
239
  const tries = candidates(cmd);
848
240
  const lineSubs = [];
849
241
  let child = null;
850
- let pending = ""; // partial line carried between chunks
242
+ // Every stdout/stderr chunk goes through here to the `onLine` subscribers.
243
+ const lines = lineFeed(lineSubs);
851
244
  let stdout = "", stderr = "";
852
245
  let timedOut = false, killed = false;
853
246
  let settle;
854
247
  const done = new Promise((resolve) => { settle = resolve; });
855
248
 
856
- // Subscribers get `(text, partial)`. A subscriber must not throw and must
857
- // tolerate repeats: `partial` is the still-unterminated tail, re-offered as
858
- // it grows, because a prompt is written WITHOUT a newline —
859
- // "Paste code here if prompted > " never terminates a line, so a
860
- // newline-only reader would wait for it forever.
861
- const emitLines = (text) => {
862
- pending += text;
863
- let nl;
864
- while ((nl = pending.indexOf("\n")) !== -1) {
865
- const line = pending.slice(0, nl).replace(/\r$/, "");
866
- pending = pending.slice(nl + 1);
867
- for (const cb of lineSubs) { try { cb(line, false); } catch { /* a subscriber must not kill the child */ } }
868
- }
869
- if (pending) {
870
- for (const cb of lineSubs) { try { cb(pending, true); } catch { /* ignore */ } }
871
- }
872
- };
873
-
874
249
  let graceTimer = null;
875
250
 
876
251
  const finish = (code, err) => {
@@ -889,14 +264,14 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
889
264
  // `cswap add` for the account the user had just cancelled, racing
890
265
  // cancelLogin's own restore, with the dialog flipping to `done`.
891
266
  //
892
- // The memo guard eleven lines below already distrusted a kill for the same
893
- // reason (`if (code === 0 && !killed && !timedOut)`), so the two halves of
894
- // this function disagreed about what a killed exit means.
267
+ // The memo guard in the child's 'close' handler below already distrusted a
268
+ // kill for the same reason (`if (code === 0 && !killed && !timedOut)`), so
269
+ // the two halves of this function disagreed about what a killed exit means.
895
270
  s({ ok: code === 0 && !err && !timedOut && !killed, code: err?.code ?? code ?? -1, killed, timedOut, stdout, stderr });
896
271
  };
897
272
 
898
- // The deadline states the outcome and only then kills — the order `run` uses
899
- // forty lines above, for the reason its own header already spells out.
273
+ // The deadline states the outcome and only then kills — the order `run`'s own
274
+ // deadline uses, for the reason run's header already spells out.
900
275
  //
901
276
  // This used to set the flag, kill, and leave `done` to the child's 'close'.
902
277
  // 'close' waits for the stdio pipes, not merely for the exit, so ONE
@@ -933,7 +308,8 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
933
308
  try {
934
309
  // Tracked for the same reason `run`'s child is, and with more at stake:
935
310
  // this one's deadline is five minutes and it is a `claude auth login`
936
- // blocked on a stdin pipe only this process holds. See `live` above.
311
+ // blocked on a stdin pipe only this process holds. See `live` in
312
+ // exec-children.mjs.
937
313
  proc = watchChild(spawn(file, argv, { stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, ...opts }));
938
314
  } catch (err) {
939
315
  return tryNext(err) ? attempt(i + 1) : finish(-1, err);
@@ -975,7 +351,7 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
975
351
  // false message "not on PATH" — even after it was installed. A batch
976
352
  // spelling is confirmed by the clean exit below instead, which is the rule
977
353
  // `run` already applies with `if (!err)`.
978
- if (!isBatch(raw)) proc.on("spawn", () => resolved.set(cmd, raw));
354
+ if (!isBatch(raw)) proc.on("spawn", () => rememberSpelling(cmd, raw));
979
355
  // Capped so a runaway child cannot grow the heap without bound; the tail is
980
356
  // what carries the error, so the head is what gets dropped.
981
357
  const keep = (buf, text) => (buf + text).slice(-maxOutput);
@@ -984,8 +360,8 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
984
360
  // become two replacement characters in the line it emits.
985
361
  proc.stdout?.setEncoding?.("utf8");
986
362
  proc.stderr?.setEncoding?.("utf8");
987
- proc.stdout?.on("data", (d) => { if (stale()) return; const t = String(d); stdout = keep(stdout, t); emitLines(t); });
988
- proc.stderr?.on("data", (d) => { if (stale()) return; const t = String(d); stderr = keep(stderr, t); emitLines(t); });
363
+ proc.stdout?.on("data", (d) => { if (stale()) return; const t = String(d); stdout = keep(stdout, t); lines.push(t); });
364
+ proc.stderr?.on("data", (d) => { if (stale()) return; const t = String(d); stderr = keep(stderr, t); lines.push(t); });
989
365
  proc.on("close", (code) => {
990
366
  if (stale()) return;
991
367
  // Already answered — by the deadline above, or by kill()'s grace below.
@@ -1001,7 +377,7 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
1001
377
  // the whole command, and these commands remove accounts.
1002
378
  if (code !== 0 && isBatch(raw) && looksMissing(`${stderr}\n${stdout}`, launch, code)) {
1003
379
  if (i + 1 < tries.length) {
1004
- stdout = ""; stderr = ""; pending = "";
380
+ stdout = ""; stderr = ""; lines.reset();
1005
381
  child = null;
1006
382
  return attempt(i + 1);
1007
383
  }
@@ -1009,7 +385,7 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
1009
385
  }
1010
386
  // Ran to a clean exit, so this spelling is real — the only confirmation a
1011
387
  // batch one ever gets.
1012
- if (code === 0 && !killed && !timedOut) resolved.set(cmd, raw);
388
+ if (code === 0 && !killed && !timedOut) rememberSpelling(cmd, raw);
1013
389
  finish(code ?? -1, null);
1014
390
  });
1015
391
  };
@@ -1045,6 +421,43 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
1045
421
  };
1046
422
  }
1047
423
 
424
+ /**
425
+ * Cut a child's output into lines for `subs`, as it arrives.
426
+ *
427
+ * Subscribers get `(text, partial)`. A subscriber must not throw and must
428
+ * tolerate repeats: `partial` is the still-unterminated tail, re-offered as
429
+ * it grows, because a prompt is written WITHOUT a newline —
430
+ * "Paste code here if prompted > " never terminates a line, so a
431
+ * newline-only reader would wait for it forever.
432
+ *
433
+ * `subs` is read at every push rather than copied, so a subscriber added
434
+ * after the child started hears everything from then on. `reset` drops the
435
+ * carried tail, for a retry that starts the output over under the next
436
+ * spelling.
437
+ *
438
+ * Exported for its test, and for the three fakes in the suite that stand in
439
+ * for runInteractive and cut their output with it rather than with a copy of
440
+ * the rule — so it is pinned by running it rather than by reading it.
441
+ */
442
+ export function lineFeed(subs) {
443
+ let pending = ""; // partial line carried between chunks
444
+ return {
445
+ push(text) {
446
+ pending += text;
447
+ let nl;
448
+ while ((nl = pending.indexOf("\n")) !== -1) {
449
+ const line = pending.slice(0, nl).replace(/\r$/, "");
450
+ pending = pending.slice(nl + 1);
451
+ for (const cb of subs) { try { cb(line, false); } catch { /* a subscriber must not kill the child */ } }
452
+ }
453
+ if (pending) {
454
+ for (const cb of subs) { try { cb(pending, true); } catch { /* ignore */ } }
455
+ }
456
+ },
457
+ reset() { pending = ""; },
458
+ };
459
+ }
460
+
1048
461
  /**
1049
462
  * Start a command and don't wait for it. Same resolution, no output captured.
1050
463
  * Used where the result lands somewhere else — a file the next poll reads, or
@@ -1066,8 +479,8 @@ export function runDetached(cmd, args) {
1066
479
  // Same trap as above: a batch spelling is spawned through cmd.exe, which
1067
480
  // succeeds whether or not the batch file is there, so only a clean exit
1068
481
  // proves this one is worth remembering.
1069
- if (isBatch(raw)) child.on("exit", (code) => { if (code === 0) resolved.set(cmd, raw); });
1070
- else child.on("spawn", () => resolved.set(cmd, raw));
482
+ if (isBatch(raw)) child.on("exit", (code) => { if (code === 0) rememberSpelling(cmd, raw); });
483
+ else child.on("spawn", () => rememberSpelling(cmd, raw));
1071
484
  child.unref?.();
1072
485
  } catch {
1073
486
  attempt(i + 1);