@cortexkit/opencode-anthropic-auth 2.2.8 → 2.4.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 (41) hide show
  1. package/dist/cli.js +45 -42
  2. package/dist/index-32m0nard.js +43 -0
  3. package/dist/index-46e2gk3c.js +2 -0
  4. package/dist/index-dpnhamzw.js +2 -0
  5. package/dist/index-gvswxab8.js +2 -0
  6. package/dist/index-hc7bm3yf.js +1 -0
  7. package/dist/index.js +1 -1
  8. package/dist/plugin-test-handles.d.ts +26 -0
  9. package/dist/rpc/port-file.d.ts +6 -0
  10. package/dist/rpc/port-file.js +1 -1
  11. package/dist/rpc/rpc-client.js +1 -1
  12. package/dist/rpc/rpc-dir.js +1 -1
  13. package/dist/sidebar-state.d.ts +6 -3
  14. package/dist/sidebar-state.js +1 -1
  15. package/dist/tui/claude-sidebar.d.ts +58 -0
  16. package/dist/tui/menu-status.d.ts +70 -0
  17. package/dist/tui-preferences.js +2 -2
  18. package/dist/v2/adapter.d.ts +29 -5
  19. package/dist/v2/cachekeep.d.ts +10 -1
  20. package/dist/v2/server.js +10 -10
  21. package/dist/v2/tui.d.ts +12 -0
  22. package/dist/v2/tui.js +4 -5
  23. package/package.json +2 -2
  24. package/src/rpc/port-file.ts +11 -1
  25. package/src/rpc/rpc-dir.ts +7 -2
  26. package/src/sidebar-state.ts +55 -46
  27. package/src/tui/claude-sidebar.tsx +155 -15
  28. package/src/tui/entry.mjs +13 -0
  29. package/src/tui/menu-status.tsx +300 -0
  30. package/src/tui-compiled/rpc/port-file.ts +11 -1
  31. package/src/tui-compiled/rpc/rpc-dir.ts +7 -2
  32. package/src/tui-compiled/sidebar-state.ts +55 -46
  33. package/src/tui-compiled/tui/claude-sidebar.tsx +112 -12
  34. package/src/tui-compiled/tui/menu-status.tsx +388 -0
  35. package/src/tui-compiled/tui-preferences.ts +32 -3
  36. package/src/tui-preferences.ts +32 -3
  37. package/dist/index-0jd4jg49.js +0 -43
  38. package/dist/index-bcp23wwb.js +0 -2
  39. package/dist/index-c8ehrd0p.js +0 -1
  40. package/dist/index-j6x60aqq.js +0 -2
  41. package/dist/index-nm4pjrt8.js +0 -2
@@ -0,0 +1,388 @@
1
+ import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid";
2
+ import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid";
3
+ import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid";
4
+ import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid";
5
+ import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid";
6
+ import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid";
7
+ import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid";
8
+ import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid";
9
+ /** @jsxImportSource @opentui/solid */
10
+
11
+ // The status view of the OpenCode 2 `/claude` menu. Core writes a section's
12
+ // status as plain lines, some of them Markdown: the quota summary has `##`
13
+ // headings and indented `-` bullets. This view draws headings and bullets as
14
+ // styled text instead of showing their markers. For the Quota section it draws
15
+ // the account blocks of OpenCode 1's quota view, read from the same server
16
+ // state as the sidebar, and falls back to the text when that read fails.
17
+
18
+ import { TextAttributes } from "opentui:runtime-module:%40opentui%2Fcore";
19
+ import { createSignal, For, Show } from "opentui:runtime-module:solid-js";
20
+ import { readTuiPreferencesFile, resolveAnthropicAuthPrefs } from "../tui-preferences.js";
21
+ import { AccountBlock, sidebarThemeFromHost } from "./claude-sidebar.js";
22
+
23
+ /** One status line, classified for drawing. */
24
+
25
+ // The widest indent kept for a bullet; deeper nesting is drawn at this indent.
26
+ const MAX_BULLET_INDENT = 8;
27
+
28
+ // The width of OpenCode 1's quota view (`QuotaDialogContent` in `tui.tsx`).
29
+ const QUOTA_VIEW_WIDTH = 58;
30
+
31
+ /**
32
+ * Splits a section's status lines (each may hold several lines) and classifies
33
+ * each one. Only line-leading Markdown is read: `#` headings and `-`, `*` or
34
+ * `+` bullets. Everything else, including any `*` inside a line, stays as
35
+ * written. Runs of blank lines collapse to one, and leading or trailing blank
36
+ * lines are dropped.
37
+ */
38
+ export function parseStatusLines(lines) {
39
+ const parsed = [];
40
+ for (const raw of lines.join('\n').split(/\r?\n/)) {
41
+ const line = raw.trimEnd();
42
+ if (line.trim() === '') {
43
+ if (parsed.length && parsed.at(-1)?.kind !== 'blank') parsed.push({
44
+ kind: 'blank'
45
+ });
46
+ continue;
47
+ }
48
+ const heading = /^\s{0,3}(#{1,6})\s+(.+?)(?:\s+#+)?$/.exec(line);
49
+ if (heading?.[1] && heading[2]) {
50
+ parsed.push({
51
+ kind: 'heading',
52
+ level: heading[1].length,
53
+ text: heading[2]
54
+ });
55
+ continue;
56
+ }
57
+ const bullet = /^(\s*)[-*+]\s+(.+)$/.exec(line);
58
+ if (bullet?.[2]) {
59
+ parsed.push({
60
+ kind: 'bullet',
61
+ indent: Math.min(bullet[1]?.length ?? 0, MAX_BULLET_INDENT),
62
+ text: bullet[2]
63
+ });
64
+ continue;
65
+ }
66
+ parsed.push({
67
+ kind: 'text',
68
+ text: line
69
+ });
70
+ }
71
+ if (parsed.at(-1)?.kind === 'blank') parsed.pop();
72
+ return parsed;
73
+ }
74
+
75
+ /** Draws one status line; headings and bullets lose their Markdown markers. */
76
+ function StatusRow(props) {
77
+ const line = props.line;
78
+ switch (line.kind) {
79
+ case 'heading':
80
+ return (() => {
81
+ var _el$ = _$createElement("text");
82
+ _$insert(_el$, () => line.text);
83
+ _$effect(_p$ => {
84
+ var _v$ = line.level <= 2 ? props.theme.accent : props.theme.text,
85
+ _v$2 = TextAttributes.BOLD;
86
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$, "fg", _v$, _p$.e));
87
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "attributes", _v$2, _p$.t));
88
+ return _p$;
89
+ }, {
90
+ e: undefined,
91
+ t: undefined
92
+ });
93
+ return _el$;
94
+ })();
95
+ case 'bullet':
96
+ return (// The text sits in its own column, so a long bullet wraps under its
97
+ // first word instead of under the bullet mark.
98
+ (() => {
99
+ var _el$2 = _$createElement("box"),
100
+ _el$3 = _$createElement("text"),
101
+ _el$5 = _$createElement("text");
102
+ _$insertNode(_el$2, _el$3);
103
+ _$insertNode(_el$2, _el$5);
104
+ _$setProp(_el$2, "flexDirection", 'row');
105
+ _$insertNode(_el$3, _$createTextNode(`• `));
106
+ _$setProp(_el$3, "flexShrink", 0);
107
+ _$setProp(_el$5, "flexGrow", 1);
108
+ _$setProp(_el$5, "flexShrink", 1);
109
+ _$insert(_el$5, () => line.text);
110
+ _$effect(_p$ => {
111
+ var _v$3 = line.indent,
112
+ _v$4 = props.theme.textMuted,
113
+ _v$5 = props.theme.text;
114
+ _v$3 !== _p$.e && (_p$.e = _$setProp(_el$2, "paddingLeft", _v$3, _p$.e));
115
+ _v$4 !== _p$.t && (_p$.t = _$setProp(_el$3, "fg", _v$4, _p$.t));
116
+ _v$5 !== _p$.a && (_p$.a = _$setProp(_el$5, "fg", _v$5, _p$.a));
117
+ return _p$;
118
+ }, {
119
+ e: undefined,
120
+ t: undefined,
121
+ a: undefined
122
+ });
123
+ return _el$2;
124
+ })()
125
+ );
126
+ case 'text':
127
+ return (() => {
128
+ var _el$6 = _$createElement("text");
129
+ _$insert(_el$6, () => line.text);
130
+ _$effect(_$p => _$setProp(_el$6, "fg", props.theme.text, _$p));
131
+ return _el$6;
132
+ })();
133
+ default:
134
+ return (() => {
135
+ var _el$7 = _$createElement("box");
136
+ _$setProp(_el$7, "height", 1);
137
+ return _el$7;
138
+ })();
139
+ }
140
+ }
141
+
142
+ /** Draws status lines with headings and bullets styled, not as Markdown. */
143
+ export function StatusText(props) {
144
+ return (() => {
145
+ var _el$8 = _$createElement("box");
146
+ _$setProp(_el$8, "flexDirection", 'column');
147
+ _$setProp(_el$8, "width", '100%');
148
+ _$insert(_el$8, _$createComponent(For, {
149
+ get each() {
150
+ return parseStatusLines(props.lines);
151
+ },
152
+ children: line => _$createComponent(StatusRow, {
153
+ line: line,
154
+ get theme() {
155
+ return props.theme;
156
+ }
157
+ })
158
+ }));
159
+ return _el$8;
160
+ })();
161
+ }
162
+
163
+ /**
164
+ * OpenCode 1's quota view: one account block for main and for each enabled or
165
+ * vault-held fallback, as the sidebar draws them.
166
+ */
167
+ export function QuotaStatus(props) {
168
+ const fallbacks = () => (props.state.fallbacks ?? []).filter(fallback => fallback.enabled || fallback.custodyState && fallback.custodyState !== 'off');
169
+ return (// As wide as OpenCode 1's quota view: wider, the reset times drift far
170
+ // from the bars they belong to.
171
+ (() => {
172
+ var _el$9 = _$createElement("box");
173
+ _$setProp(_el$9, "flexDirection", 'column');
174
+ _$setProp(_el$9, "width", '100%');
175
+ _$setProp(_el$9, "maxWidth", 58);
176
+ _$insert(_el$9, _$createComponent(AccountBlock, {
177
+ get theme() {
178
+ return props.theme;
179
+ },
180
+ get appearance() {
181
+ return props.prefs.appearance;
182
+ },
183
+ name: "main",
184
+ get quota() {
185
+ return props.state.main?.quota;
186
+ },
187
+ get active() {
188
+ return props.state.activeId === 'main';
189
+ },
190
+ get pacingEnabled() {
191
+ return props.prefs.sections.pacing;
192
+ },
193
+ get tierLabel() {
194
+ return props.state.main?.tierLabel;
195
+ },
196
+ get stateLine() {
197
+ return props.state.main?.stateLine;
198
+ },
199
+ get quotaDated() {
200
+ return props.state.main?.quotaDated;
201
+ },
202
+ get now() {
203
+ return props.now;
204
+ }
205
+ }), null);
206
+ _$insert(_el$9, _$createComponent(Show, {
207
+ get when() {
208
+ return props.prefs.sections.fallbackAccounts;
209
+ },
210
+ get children() {
211
+ return _$createComponent(For, {
212
+ get each() {
213
+ return fallbacks();
214
+ },
215
+ children: fallback => _$createComponent(AccountBlock, {
216
+ get theme() {
217
+ return props.theme;
218
+ },
219
+ get appearance() {
220
+ return props.prefs.appearance;
221
+ },
222
+ get name() {
223
+ return fallback.label ?? fallback.id;
224
+ },
225
+ get quota() {
226
+ return fallback.quota;
227
+ },
228
+ get active() {
229
+ return props.state.activeId === fallback.id;
230
+ },
231
+ get pacingEnabled() {
232
+ return props.prefs.sections.pacing;
233
+ },
234
+ get tierLabel() {
235
+ return fallback.tierLabel;
236
+ },
237
+ get needsReauth() {
238
+ return fallback.needsReauth;
239
+ },
240
+ get vaultReauth() {
241
+ return fallback.vaultReauth;
242
+ },
243
+ get stateLine() {
244
+ return fallback.stateLine;
245
+ },
246
+ get quotaDated() {
247
+ return fallback.quotaDated;
248
+ },
249
+ marginTop: 1,
250
+ get now() {
251
+ return props.now;
252
+ }
253
+ })
254
+ });
255
+ }
256
+ }), null);
257
+ return _el$9;
258
+ })()
259
+ );
260
+ }
261
+
262
+ // --- OpenCode 2 -------------------------------------------------------------
263
+
264
+ /** One section's status, as the `/claude` menu hands it over. */
265
+
266
+ /** The OpenCode 2 TUI context members the status dialog uses. */
267
+
268
+ /**
269
+ * Shows a section's status in a wide OpenCode 2 dialog. The returned promise
270
+ * settles when the person closes the dialog, or when `signal` aborts.
271
+ */
272
+ export function showMenuStatusDialog(host, view, signal) {
273
+ return new Promise(resolve => {
274
+ if (signal?.aborted) return resolve();
275
+ // Stops the quota read once the dialog closes or the menu goes away.
276
+ const reading = new AbortController();
277
+ const close = () => {
278
+ reading.abort();
279
+ resolve();
280
+ };
281
+ signal?.addEventListener('abort', close, {
282
+ once: true
283
+ });
284
+ const [quota, setQuota] = createSignal({
285
+ kind: 'loading'
286
+ });
287
+ const readQuota = view.readQuota;
288
+ if (readQuota) void Promise.all([readQuota(reading.signal), readTuiPreferencesFile().then(resolveAnthropicAuthPrefs)]).then(([state, prefs]) => {
289
+ if (!reading.signal.aborted) setQuota({
290
+ kind: 'ready',
291
+ state,
292
+ prefs
293
+ });
294
+ }, () => {
295
+ // An older server without the sidebar method, or a failed read:
296
+ // the status text still says everything the blocks would.
297
+ if (!reading.signal.aborted) setQuota({
298
+ kind: 'failed'
299
+ });
300
+ });
301
+ const ready = () => {
302
+ const current = quota();
303
+ return current.kind === 'ready' ? current : undefined;
304
+ };
305
+ const theme = () => sidebarThemeFromHost(host.theme);
306
+ host.ui.dialog.show(() => (() => {
307
+ var _el$0 = _$createElement("box"),
308
+ _el$1 = _$createElement("box"),
309
+ _el$10 = _$createElement("text"),
310
+ _el$11 = _$createElement("box"),
311
+ _el$12 = _$createElement("text");
312
+ _$insertNode(_el$0, _el$1);
313
+ _$insertNode(_el$0, _el$11);
314
+ _$setProp(_el$0, "flexDirection", 'column');
315
+ _$setProp(_el$0, "paddingLeft", 2);
316
+ _$setProp(_el$0, "paddingRight", 2);
317
+ _$insertNode(_el$1, _el$10);
318
+ _$setProp(_el$1, "paddingBottom", 1);
319
+ _$insert(_el$10, () => view.title);
320
+ _$insert(_el$0, _$createComponent(Show, {
321
+ get when() {
322
+ return readQuota && quota().kind !== 'failed';
323
+ },
324
+ get fallback() {
325
+ return _$createComponent(StatusText, {
326
+ get lines() {
327
+ return view.lines;
328
+ },
329
+ get theme() {
330
+ return theme();
331
+ }
332
+ });
333
+ },
334
+ get children() {
335
+ return _$createComponent(Show, {
336
+ get when() {
337
+ return ready();
338
+ },
339
+ get fallback() {
340
+ return (() => {
341
+ var _el$14 = _$createElement("text");
342
+ _$insertNode(_el$14, _$createTextNode(`Reading quota…`));
343
+ _$effect(_$p => _$setProp(_el$14, "fg", theme().textMuted, _$p));
344
+ return _el$14;
345
+ })();
346
+ },
347
+ children: ready => _$createComponent(QuotaStatus, {
348
+ get state() {
349
+ return ready().state;
350
+ },
351
+ get prefs() {
352
+ return ready().prefs;
353
+ },
354
+ get theme() {
355
+ return theme();
356
+ },
357
+ get now() {
358
+ return Date.now();
359
+ }
360
+ })
361
+ });
362
+ }
363
+ }), _el$11);
364
+ _$insertNode(_el$11, _el$12);
365
+ _$setProp(_el$11, "paddingTop", 1);
366
+ _$insertNode(_el$12, _$createTextNode(`Press Esc to close.`));
367
+ _$effect(_p$ => {
368
+ var _v$6 = theme().text,
369
+ _v$7 = TextAttributes.BOLD,
370
+ _v$8 = theme().textMuted;
371
+ _v$6 !== _p$.e && (_p$.e = _$setProp(_el$10, "fg", _v$6, _p$.e));
372
+ _v$7 !== _p$.t && (_p$.t = _$setProp(_el$10, "attributes", _v$7, _p$.t));
373
+ _v$8 !== _p$.a && (_p$.a = _$setProp(_el$12, "fg", _v$8, _p$.a));
374
+ return _p$;
375
+ }, {
376
+ e: undefined,
377
+ t: undefined,
378
+ a: undefined
379
+ });
380
+ return _el$0;
381
+ })(), close);
382
+ // The widest dialog gives the account blocks their full width and wraps
383
+ // status text as little as it can.
384
+ host.ui.dialog.set({
385
+ size: 'xlarge'
386
+ });
387
+ });
388
+ }
@@ -1,4 +1,4 @@
1
- import { readFileSync, watch } from 'node:fs'
1
+ import { readFileSync, statSync, watch } from 'node:fs'
2
2
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { basename, dirname, join } from 'node:path'
@@ -274,6 +274,20 @@ export function queueTuiPreferenceUpdate(
274
274
  const WATCH_DEBOUNCE_MS = 150
275
275
  const WATCH_POLL_MS = 100
276
276
 
277
+ // Identity of the preferences file's current version: its modification time,
278
+ // size and inode. A replacement via rename (how editors and our own writes
279
+ // save it) always changes the inode, so one `stat` per poll is enough to know
280
+ // that nothing was rewritten. The nanosecond timestamps, rather than Date's
281
+ // millisecond rounding, keep two saves within one millisecond distinguishable.
282
+ function statSignature(file: string): string | null {
283
+ try {
284
+ const info = statSync(file, { bigint: true })
285
+ return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`
286
+ } catch {
287
+ return null
288
+ }
289
+ }
290
+
277
291
  // Watches the directory rather than the file: editors and our own atomic
278
292
  // writes replace the file via rename, which kills file-level watchers.
279
293
  //
@@ -308,11 +322,23 @@ export function watchTuiPreferences(
308
322
  // Missing/unreadable files can still be created later and detected by the
309
323
  // directory watcher or polling fallback.
310
324
  }
325
+ // The stat of the file that `lastSeen` was read from. A file whose mtime,
326
+ // size and inode all match it cannot have changed, so a poll skips the read.
327
+ let lastStat = statSignature(file)
311
328
  let disposed = false
312
329
 
313
- const checkForChange = async () => {
330
+ const checkForChange = async (readAnyway = false) => {
331
+ const current = await statSignature(file)
332
+ // An absent file cannot have new content; re-reading it would only fail.
333
+ if (current === null) return
334
+ const unchanged = current === lastStat && lastSeen !== null
335
+ // The watcher path always reads, even when the stat looks familiar, so a
336
+ // hand edit inside one timestamp tick is still noticed there. Only the
337
+ // poll relies on the stat to skip a read.
338
+ if (unchanged && !readAnyway) return
314
339
  const text = await readFile(file, 'utf8').catch(() => null)
315
340
  if (disposed || text === null) return
341
+ lastStat = current
316
342
  if (text === lastSeen) return
317
343
  lastSeen = text
318
344
  onChange()
@@ -322,7 +348,7 @@ export function watchTuiPreferences(
322
348
  if (timer) nativeClearTimeout(timer)
323
349
  timer = nativeSetTimeout(() => {
324
350
  timer = null
325
- void checkForChange()
351
+ void checkForChange(true)
326
352
  }, WATCH_DEBOUNCE_MS)
327
353
  }
328
354
 
@@ -351,6 +377,9 @@ export function watchTuiPreferences(
351
377
  // repeated close/recreate cycles, so polling must be independent of watcher
352
378
  // construction. Recursive unreferenced timers avoid fs.watchFile's lingering
353
379
  // handles while the host's normal event loop keeps active watchers polling.
380
+ // A poll only reads the file when its stat changed; one controller per
381
+ // terminal polled at 10 Hz, so an unconditional read ran ten times a second
382
+ // per terminal.
354
383
  let pollTimer: ReturnType<typeof nativeSetTimeout> | null = null
355
384
  let pollInFlight = false
356
385
  const schedulePoll = () => {
@@ -1,4 +1,4 @@
1
- import { readFileSync, watch } from 'node:fs'
1
+ import { readFileSync, statSync, watch } from 'node:fs'
2
2
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { basename, dirname, join } from 'node:path'
@@ -274,6 +274,20 @@ export function queueTuiPreferenceUpdate(
274
274
  const WATCH_DEBOUNCE_MS = 150
275
275
  const WATCH_POLL_MS = 100
276
276
 
277
+ // Identity of the preferences file's current version: its modification time,
278
+ // size and inode. A replacement via rename (how editors and our own writes
279
+ // save it) always changes the inode, so one `stat` per poll is enough to know
280
+ // that nothing was rewritten. The nanosecond timestamps, rather than Date's
281
+ // millisecond rounding, keep two saves within one millisecond distinguishable.
282
+ function statSignature(file: string): string | null {
283
+ try {
284
+ const info = statSync(file, { bigint: true })
285
+ return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`
286
+ } catch {
287
+ return null
288
+ }
289
+ }
290
+
277
291
  // Watches the directory rather than the file: editors and our own atomic
278
292
  // writes replace the file via rename, which kills file-level watchers.
279
293
  //
@@ -308,11 +322,23 @@ export function watchTuiPreferences(
308
322
  // Missing/unreadable files can still be created later and detected by the
309
323
  // directory watcher or polling fallback.
310
324
  }
325
+ // The stat of the file that `lastSeen` was read from. A file whose mtime,
326
+ // size and inode all match it cannot have changed, so a poll skips the read.
327
+ let lastStat = statSignature(file)
311
328
  let disposed = false
312
329
 
313
- const checkForChange = async () => {
330
+ const checkForChange = async (readAnyway = false) => {
331
+ const current = await statSignature(file)
332
+ // An absent file cannot have new content; re-reading it would only fail.
333
+ if (current === null) return
334
+ const unchanged = current === lastStat && lastSeen !== null
335
+ // The watcher path always reads, even when the stat looks familiar, so a
336
+ // hand edit inside one timestamp tick is still noticed there. Only the
337
+ // poll relies on the stat to skip a read.
338
+ if (unchanged && !readAnyway) return
314
339
  const text = await readFile(file, 'utf8').catch(() => null)
315
340
  if (disposed || text === null) return
341
+ lastStat = current
316
342
  if (text === lastSeen) return
317
343
  lastSeen = text
318
344
  onChange()
@@ -322,7 +348,7 @@ export function watchTuiPreferences(
322
348
  if (timer) nativeClearTimeout(timer)
323
349
  timer = nativeSetTimeout(() => {
324
350
  timer = null
325
- void checkForChange()
351
+ void checkForChange(true)
326
352
  }, WATCH_DEBOUNCE_MS)
327
353
  }
328
354
 
@@ -351,6 +377,9 @@ export function watchTuiPreferences(
351
377
  // repeated close/recreate cycles, so polling must be independent of watcher
352
378
  // construction. Recursive unreferenced timers avoid fs.watchFile's lingering
353
379
  // handles while the host's normal event loop keeps active watchers polling.
380
+ // A poll only reads the file when its stat changed; one controller per
381
+ // terminal polled at 10 Hz, so an unconditional read ran ten times a second
382
+ // per terminal.
354
383
  let pollTimer: ReturnType<typeof nativeSetTimeout> | null = null
355
384
  let pollInFlight = false
356
385
  const schedulePoll = () => {