@yolo-labs/yolobridge 0.1.0 → 0.2.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.
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Pre-trusts the `yolo-studio` MCP server `local-mcp-config.ts` writes into
3
+ * `.mcp.json`, by writing a companion project-scoped
4
+ * `.claude/settings.local.json` entry in the same `yolo-bridge attach`
5
+ * spawn `cwd`.
6
+ *
7
+ * Without this, Claude Code shows "New MCP server found in this project:
8
+ * yolo-studio" and every one of its tool calls requires manual approval --
9
+ * defeating the point of `send_to_tile` reaching a session that then just
10
+ * sits there waiting on an interactive prompt nobody's watching for.
11
+ *
12
+ * Two SEPARATE approval layers, both need covering (confirmed against
13
+ * current Claude Code docs, not assumed):
14
+ * 1. Server discovery/trust ("New MCP server found") -- `enabledMcpjsonServers`.
15
+ * 2. Per-tool-call approval -- `permissions.allow` with an
16
+ * `mcp__<server>__<tool>` pattern (`*` wildcard for "every tool this
17
+ * server exposes"; Claude Code's own docs, matched independently
18
+ * against a Codex review finding 2026-08-24 -- see below).
19
+ *
20
+ * Deliberately narrow, same reasoning as `local-mcp-config.ts`: this adds
21
+ * ONLY `enabledMcpjsonServers: ["yolo-studio"]` and
22
+ * `permissions.allow: ["mcp__yolo-studio__*"]` -- never
23
+ * `enableAllProjectMcpServers` (would trust future/unrelated MCP servers
24
+ * too) and never `--dangerously-skip-permissions`-equivalent blanket rules.
25
+ * Every other key in an existing settings file (the user's own permissions,
26
+ * hooks, etc.) is preserved untouched, same "only touch what we own"
27
+ * discipline as the `.mcp.json` writer.
28
+ *
29
+ * Targets `settings.local.json`, NOT the shared `settings.json` (Codex
30
+ * review, 2026-08-24, round 14 — this repo's OWN root already has a
31
+ * tracked `.claude/settings.json`): the shared file is meant to be
32
+ * committed and shared across a team, but this grant is per-attach,
33
+ * ephemeral daemon state — if written there, a spawned coding agent
34
+ * running with YOLO-mode autonomy could commit/push it, and a SIGKILL or
35
+ * reboot before cleanup would leave it in a file every OTHER checkout of
36
+ * the repo inherits, auto-trusting a future `yolo-studio` server
37
+ * definition with no prompt. `settings.local.json` is Claude Code's own
38
+ * sanctioned personal/machine-local settings layer (merged with
39
+ * `settings.json`), CONVENTIONALLY git-ignored.
40
+ *
41
+ * "Conventionally" is not "guaranteed" (Codex review, 2026-08-24, round 15):
42
+ * that assumption doesn't hold universally, and disproved itself in the one
43
+ * place it would matter most — this repo's OWN root ALREADY tracks
44
+ * `.claude/settings.local.json` (verified with `git ls-files`, not assumed).
45
+ * Trusting any specific filename's reputation is therefore the wrong check.
46
+ * `writeLocalMcpTrust` now asks git directly (`git check-ignore`) whether
47
+ * THIS path, in THIS `cwd`, is actually safe from being swept into a
48
+ * commit, and refuses to write anything (falling back to `ok: false`,
49
+ * the same degraded "manual approval required" path an unparseable file
50
+ * already takes) if it isn't — never writing INTO a tracked-or-trackable
51
+ * file is the only way to make the "never persisted where Git can see it"
52
+ * guarantee actually hold, regardless of which filename convention this
53
+ * particular checkout happens to follow.
54
+ *
55
+ * KNOWN GOTCHA, not fixable from here: project-scoped settings are ignored
56
+ * in an UNTRUSTED folder until the user interactively trusts the workspace
57
+ * once (Claude Code v2.1.196+). `yolo-bridge attach` spawns into the
58
+ * directory the command itself was run from -- normally the user's own
59
+ * long-lived working directory, already trusted from ordinary use, not a
60
+ * fresh temp dir -- so this shouldn't bite in the common case, but a truly
61
+ * first-ever `attach` in a brand-new directory would still see one manual
62
+ * trust prompt before this takes effect.
63
+ */
64
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
65
+ import { join, dirname, basename } from 'node:path';
66
+ import { atomicWriteFileSync, unlinkWriteTarget, resolveWriteTarget } from './atomic-write.js';
67
+ import { randomUUID } from 'node:crypto';
68
+ import { riskyToCommit, ensureTempSiblingExcluded } from './git-safety.js';
69
+ /** Same identity as local-mcp-config.ts's SERVER_NAME -- kept as an
70
+ * independent constant (not imported) since these two modules are meant to
71
+ * be usable/testable independently of each other. */
72
+ const SERVER_NAME = 'yolo-studio';
73
+ // Shell-glob wildcard, NOT regex -- Claude Code's permission matching treats
74
+ // `*` as "any tool from this server" (docs.claude.com/en/docs/claude-code/
75
+ // permissions, "tool name wildcards"). A `.*` here (as this constant
76
+ // originally read) requires a literal dot before the wildcard, which no real
77
+ // `mcp__yolo-studio__<tool>` id has -- so it silently matched NOTHING, and
78
+ // pre-trust never actually worked: every tool call still prompted for manual
79
+ // approval. Found by Codex review (2026-08-24), confirmed independently
80
+ // against current docs before fixing (not just taken on faith).
81
+ const TOOL_PATTERN = `mcp__${SERVER_NAME}__*`;
82
+ function settingsPath(cwd) {
83
+ return join(cwd, '.claude', 'settings.local.json');
84
+ }
85
+ function readSettings(path) {
86
+ if (!existsSync(path))
87
+ return {};
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
91
+ }
92
+ catch {
93
+ throw new Error(`existing ${path} is not valid JSON`);
94
+ }
95
+ // Same refuse-rather-than-clobber fix as local-mcp-config.ts's
96
+ // readConfig (Codex review, 2026-08-24) — a JSON array root passed the
97
+ // old truthy-and-typeof-object check and would have been cast straight
98
+ // into Record<string, unknown>.
99
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
100
+ throw new Error(`existing ${path} is not a JSON object`);
101
+ }
102
+ return parsed;
103
+ }
104
+ function asStringArray(value) {
105
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
106
+ }
107
+ /**
108
+ * Top-level marker key recording exactly which entries THIS module added,
109
+ * so a LATER attach (after a crash) can tell "this is ours to reclaim" with
110
+ * certainty instead of guessing (Codex review, 2026-08-24, round 7 —
111
+ * mirrors `local-mcp-config.ts`'s `OWNERSHIP_MARKER`, same reasoning: a
112
+ * content-only heuristic can't distinguish our own leftover state from the
113
+ * operator's genuinely independent choice, since both look identical once
114
+ * written). Claude Code's settings schema tolerates unrelated top-level
115
+ * keys the same way `.mcp.json` does — this module already preserves the
116
+ * operator's own such keys untouched.
117
+ */
118
+ const OWNERSHIP_MARKER = '_yolobridge';
119
+ /**
120
+ * True only when `raw` has EXACTLY this module's own marker shape — no
121
+ * unexpected keys, and every present field correctly typed (Codex review,
122
+ * 2026-08-24, round 30): the previous check only confirmed "some object,"
123
+ * blindly casting it as `OwnershipMarker` — an operator with their OWN
124
+ * unrelated `_yolobridge` top-level key (however unlikely a name collision
125
+ * this is) would have it treated as this module's own record, letting
126
+ * `writeLocalMcpTrust` OVERWRITE it unconditionally and `removeLocalMcpTrust`
127
+ * later DELETE it outright, destroying data this module never owned. Same
128
+ * "present-but-invalid is refused" convention this file already applies to
129
+ * `enabledMcpjsonServers`/`permissions` — this is the one spot that skipped
130
+ * it.
131
+ */
132
+ function looksLikeOurOwnMarker(raw) {
133
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
134
+ return false;
135
+ const obj = raw;
136
+ const allowedKeys = new Set(['attachId', 'enabledServerEntry', 'permissionEntry']);
137
+ for (const key of Object.keys(obj)) {
138
+ if (!allowedKeys.has(key))
139
+ return false;
140
+ }
141
+ if ('attachId' in obj && typeof obj.attachId !== 'string')
142
+ return false;
143
+ if ('enabledServerEntry' in obj && typeof obj.enabledServerEntry !== 'boolean')
144
+ return false;
145
+ if ('permissionEntry' in obj && typeof obj.permissionEntry !== 'boolean')
146
+ return false;
147
+ return true;
148
+ }
149
+ function readOwnershipMarker(settings) {
150
+ const raw = settings[OWNERSHIP_MARKER];
151
+ if (!looksLikeOurOwnMarker(raw))
152
+ return {};
153
+ return raw;
154
+ }
155
+ /**
156
+ * Adds the `yolo-studio` MCP-trust entries to `.claude/settings.local.json`.
157
+ * Returns `ok: false` (does nothing further) if an existing settings file
158
+ * can't be parsed, rather than overwriting a file the user hand-authored --
159
+ * matches `writeLocalMcpConfig`'s own refusal behavior exactly. Also
160
+ * returns `ok: false` if `riskyToCommit` confirms this exact path is NOT
161
+ * git-ignored inside a real repo at `cwd` (Codex review, 2026-08-24,
162
+ * round 15) -- degrading to "every tool call needs manual approval" is
163
+ * strictly preferable to writing an auto-trust grant somewhere a commit
164
+ * could publish it.
165
+ *
166
+ * Tracks which entries it ACTUALLY inserted vs. which were already present
167
+ * (Codex review, 2026-08-24): if the operator had already trusted this
168
+ * server themselves before ever running `attach`, that's their own
169
+ * standing choice, not something this attach owns -- `removeLocalMcpTrust`
170
+ * must be told which entries to remove rather than unconditionally
171
+ * stripping by value, or a detach would revoke trust the operator granted
172
+ * independently.
173
+ */
174
+ export function writeLocalMcpTrust(cwd) {
175
+ const path = settingsPath(cwd);
176
+ if (riskyToCommit(cwd, path)) {
177
+ return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
178
+ }
179
+ // The DESTINATION is confirmed safe above, but `atomicWriteFileSync`'s own
180
+ // `.tmp-*` temp sibling has a DIFFERENT literal name an exact-match
181
+ // `.gitignore` entry doesn't cover (Codex review, 2026-08-24, round 25) —
182
+ // see `ensureTempSiblingExcluded`'s own doc comment. Derived from
183
+ // `resolveWriteTarget`, not the lexical path (Codex review, 2026-08-24,
184
+ // round 27) — see local-mcp-config.ts's identical fix for why.
185
+ // `?? path` (Codex review, 2026-08-24, round 31) — see
186
+ // local-mcp-config.ts's identical fix for why.
187
+ const resolvedSettingsPath = resolveWriteTarget(path) ?? path;
188
+ ensureTempSiblingExcluded(cwd, dirname(resolvedSettingsPath), `${basename(resolvedSettingsPath)}.tmp-*`);
189
+ const createdFile = !existsSync(path);
190
+ let settings;
191
+ try {
192
+ settings = readSettings(path);
193
+ }
194
+ catch {
195
+ return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
196
+ }
197
+ // Present-but-invalid is refused, same reasoning as local-mcp-config.ts's
198
+ // matching fix (Codex review, 2026-08-24): `asStringArray` alone silently
199
+ // treats a non-array value as `[]`, which would discard a malformed
200
+ // `enabledMcpjsonServers`/`permissions.allow` (or a non-object
201
+ // `permissions`) on write while still reporting success. Only genuinely
202
+ // ABSENT fields default to empty.
203
+ if ('enabledMcpjsonServers' in settings && !Array.isArray(settings.enabledMcpjsonServers)) {
204
+ return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
205
+ }
206
+ if ('permissions' in settings && (settings.permissions === null || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions))) {
207
+ return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
208
+ }
209
+ const permissionsObj = (settings.permissions ?? {});
210
+ if ('allow' in permissionsObj && !Array.isArray(permissionsObj.allow)) {
211
+ return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
212
+ }
213
+ // An array that's the right SHAPE can still hold the wrong ELEMENT types
214
+ // (Codex review, 2026-08-24, round 19): the checks above only confirm
215
+ // "this is an array," not "every element is a string" — `asStringArray`
216
+ // below silently filters out anything else, so a non-string element
217
+ // (e.g. `["real-server", 42]`) would be PERMANENTLY discarded from the
218
+ // operator's own file the moment this module writes it back, despite
219
+ // having nothing to do with what this module owns. Refused here for the
220
+ // same reason a malformed shape is: present-but-invalid content is not
221
+ // this module's to silently normalize away.
222
+ if (Array.isArray(settings.enabledMcpjsonServers) && !settings.enabledMcpjsonServers.every((v) => typeof v === 'string')) {
223
+ return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
224
+ }
225
+ if (Array.isArray(permissionsObj.allow) && !permissionsObj.allow.every((v) => typeof v === 'string')) {
226
+ return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
227
+ }
228
+ // Present-but-NOT-our-own-shape is refused the same way, for the
229
+ // OWNERSHIP MARKER key itself (Codex review, 2026-08-24, round 30): an
230
+ // operator's own unrelated data under `_yolobridge` must never be
231
+ // silently overwritten (write) or deleted (a later detach) just because
232
+ // this module wants to write ITS OWN marker there. A genuinely ABSENT key,
233
+ // or one that already matches this module's own marker shape (a prior,
234
+ // possibly-crashed attach's own record — the legitimate reclaim case
235
+ // round 7 introduced this key for), is the only thing safe to overwrite.
236
+ if (OWNERSHIP_MARKER in settings && !looksLikeOurOwnMarker(settings[OWNERSHIP_MARKER])) {
237
+ return { ok: false, addedServerEntry: false, addedPermissionEntry: false, createdFile: false };
238
+ }
239
+ // Consulted BEFORE mutating anything below (Codex review, 2026-08-24,
240
+ // round 7): if the entry is already present but OUR OWN marker from a
241
+ // previous (possibly crashed) attach claims it, this is a reclaim, not a
242
+ // fresh addition or a foreign one -- addedServerEntry/addedPermissionEntry
243
+ // report `true` either way, since both mean "safe for cleanup to remove."
244
+ const priorMarker = readOwnershipMarker(settings);
245
+ const enabled = new Set(asStringArray(settings.enabledMcpjsonServers));
246
+ const addedServerEntry = !enabled.has(SERVER_NAME) || priorMarker.enabledServerEntry === true;
247
+ enabled.add(SERVER_NAME);
248
+ settings.enabledMcpjsonServers = [...enabled];
249
+ const allow = new Set(asStringArray(permissionsObj.allow));
250
+ const addedPermissionEntry = !allow.has(TOOL_PATTERN) || priorMarker.permissionEntry === true;
251
+ allow.add(TOOL_PATTERN);
252
+ permissionsObj.allow = [...allow];
253
+ settings.permissions = permissionsObj;
254
+ const attachId = randomUUID();
255
+ settings[OWNERSHIP_MARKER] = { attachId, enabledServerEntry: addedServerEntry, permissionEntry: addedPermissionEntry };
256
+ mkdirSync(dirname(path), { recursive: true });
257
+ // Atomic (temp file + rename), not a direct overwrite (Codex review,
258
+ // 2026-08-24, round 13): a direct `writeFileSync` on an EXISTING
259
+ // `.claude/settings.local.json` truncates it before writing the new bytes, so
260
+ // ENOSPC or a crash mid-write can leave the OPERATOR's settings
261
+ // half-written — unrecoverable, and settings.local.json can carry far more than
262
+ // just this module's own keys.
263
+ atomicWriteFileSync(path, JSON.stringify(settings, null, 2) + '\n');
264
+ return { ok: true, addedServerEntry, addedPermissionEntry, createdFile, attachId };
265
+ }
266
+ /**
267
+ * Removes only the `yolo-studio` entries THIS attach actually added
268
+ * (`removeServerEntry`/`removePermissionEntry`, from `writeLocalMcpTrust`'s
269
+ * own return) -- never touches any other server name, allow/deny rule,
270
+ * hook, other top-level key, or an entry the operator had already granted
271
+ * before this attach. Deletes `.claude/settings.local.json` only if `createdFile`
272
+ * (also from `writeLocalMcpTrust`'s return) says THIS attachment is the one
273
+ * that created it AND nothing else was ever added to it -- Codex review,
274
+ * 2026-08-24, round 6: emptiness alone isn't proof of that (a pre-existing
275
+ * `{}` looks identical once our entries are removed); a file with other
276
+ * content, or one that already existed before this attach touched it, is
277
+ * left in place, minus just what this attach added.
278
+ *
279
+ * `opts.attachId` (from that same `writeLocalMcpTrust` call) must EXACTLY
280
+ * match the marker CURRENTLY on disk, re-read here, before anything is
281
+ * touched (Codex review, 2026-08-24, round 8) -- decisions captured at
282
+ * write time can go stale by the time cleanup actually runs: the operator
283
+ * may have edited or removed the marker mid-session (a signal to leave
284
+ * their grants alone, not something a blind boolean-driven delete should
285
+ * override), or a second concurrent attach in the same directory may have
286
+ * overwritten it with its own. A mismatched or missing marker means this
287
+ * attach can no longer prove the current state is provably its own, so it
288
+ * fails closed and leaves everything untouched rather than risk deleting
289
+ * something it doesn't actually own anymore.
290
+ */
291
+ export function removeLocalMcpTrust(cwd, opts) {
292
+ const path = settingsPath(cwd);
293
+ if (!existsSync(path))
294
+ return;
295
+ let settings;
296
+ try {
297
+ settings = readSettings(path);
298
+ }
299
+ catch {
300
+ return;
301
+ }
302
+ const currentMarker = readOwnershipMarker(settings);
303
+ const stillOurs = opts.attachId !== undefined && currentMarker.attachId === opts.attachId;
304
+ // A mismatched/missing marker means this call can no longer prove the
305
+ // current state is provably its own (edited/removed mid-session, or
306
+ // clobbered by a concurrent attach) -- touch NOTHING, not even a
307
+ // reformatting rewrite of otherwise-unchanged content.
308
+ if (!stillOurs)
309
+ return;
310
+ // Present-but-invalid is refused, same as the write path (Codex review,
311
+ // 2026-08-24, round 22): the content checks below only catch a
312
+ // WRONG-SHAPE ARRAY (a non-string element) -- they never fire when the
313
+ // value isn't an array AT ALL (hand-edited to a string/number, or
314
+ // `permissions` itself replaced with a non-object). `asStringArray`
315
+ // silently coerces any non-array to `[]`, so `enabled.length > 0` would
316
+ // be false below and this function would `delete` the operator's field
317
+ // entirely -- exactly the "cleanup destroys something it doesn't
318
+ // provably still own" failure this function's own doc comment exists to
319
+ // prevent. Only genuinely ABSENT fields are left to default to empty.
320
+ if ('enabledMcpjsonServers' in settings && !Array.isArray(settings.enabledMcpjsonServers))
321
+ return;
322
+ if ('permissions' in settings && (settings.permissions === null || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)))
323
+ return;
324
+ const permissionsForShapeCheck = (settings.permissions ?? {});
325
+ if ('allow' in permissionsForShapeCheck && !Array.isArray(permissionsForShapeCheck.allow))
326
+ return;
327
+ // Same refuse-rather-than-silently-normalize check as the write path
328
+ // (Codex review, 2026-08-24, round 19): the operator could have
329
+ // hand-edited a non-string element into one of these arrays between
330
+ // attach and detach; `asStringArray` below would otherwise drop it
331
+ // permanently the moment this function writes the file back.
332
+ if (Array.isArray(settings.enabledMcpjsonServers) && !settings.enabledMcpjsonServers.every((v) => typeof v === 'string'))
333
+ return;
334
+ if (settings.permissions && typeof settings.permissions === 'object' && !Array.isArray(settings.permissions) &&
335
+ Array.isArray(settings.permissions.allow) &&
336
+ !settings.permissions.allow.every((v) => typeof v === 'string'))
337
+ return;
338
+ if (opts.removeServerEntry) {
339
+ const enabled = asStringArray(settings.enabledMcpjsonServers).filter((s) => s !== SERVER_NAME);
340
+ if (enabled.length > 0)
341
+ settings.enabledMcpjsonServers = enabled;
342
+ else
343
+ delete settings.enabledMcpjsonServers;
344
+ }
345
+ const permissions = (settings.permissions && typeof settings.permissions === 'object' ? settings.permissions : undefined);
346
+ if (permissions && opts.removePermissionEntry) {
347
+ const allow = asStringArray(permissions.allow).filter((p) => p !== TOOL_PATTERN);
348
+ if (allow.length > 0)
349
+ permissions.allow = allow;
350
+ else
351
+ delete permissions.allow;
352
+ if (Object.keys(permissions).length > 0)
353
+ settings.permissions = permissions;
354
+ else
355
+ delete settings.permissions;
356
+ }
357
+ // Never leave the ownership marker behind — it's internal bookkeeping,
358
+ // not something the operator should see lingering in their settings once
359
+ // this attach is done with it (Codex review, 2026-08-24, round 7).
360
+ delete settings[OWNERSHIP_MARKER];
361
+ if (opts.createdFile && Object.keys(settings).length === 0) {
362
+ // `unlinkWriteTarget`, not a bare `unlinkSync(path)` (Codex review,
363
+ // 2026-08-24, round 26) — same reasoning as local-mcp-config.ts's
364
+ // identical fix: `path` can be a symlink `atomicWriteFileSync` healed.
365
+ unlinkWriteTarget(path);
366
+ return;
367
+ }
368
+ // Atomic — see the doc comment on the equivalent write in
369
+ // `writeLocalMcpTrust` (Codex review, 2026-08-24, round 13).
370
+ atomicWriteFileSync(path, JSON.stringify(settings, null, 2) + '\n');
371
+ }
package/dist/login-cmd.js CHANGED
@@ -14,13 +14,21 @@ export async function runLogin(deps) {
14
14
  const openBrowser = deps.openBrowser ?? openBrowserBestEffort;
15
15
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
16
16
  const code = await requestDeviceCode(authBaseUrl, fetchImpl);
17
+ // auth-service's own device/code response has no query string on
18
+ // verification_uri (see device-auth.ts's header comment) -- but the
19
+ // webapp's /device page (webapp/app/device/page.tsx) DOES read a `code`
20
+ // query param and pre-fills the input from it, a convention this CLI
21
+ // simply wasn't using. Building the pre-filled URL is client-side only
22
+ // (no auth-service change needed) -- the user still has to click
23
+ // Approve, so this doesn't skip any consent step, it just saves them
24
+ // re-typing a code they can already see in this terminal.
25
+ const prefilledUri = `${code.verificationUri}${code.verificationUri.includes('?') ? '&' : '?'}code=${encodeURIComponent(code.userCode)}`;
17
26
  log('To finish logging in, open this URL in your browser:');
18
- log(` ${code.verificationUri}`);
19
- log('and enter this code when prompted:');
20
- log(` ${code.userCode}`);
27
+ log(` ${prefilledUri}`);
28
+ log(`(the code ${code.userCode} is pre-filled -- if it doesn't open automatically, enter it by hand)`);
21
29
  log('');
22
30
  log('Waiting for approval...');
23
- openBrowser(code.verificationUri);
31
+ openBrowser(prefilledUri);
24
32
  const deadline = Date.now() + code.expiresInSec * 1000;
25
33
  const intervalMs = Math.max(1, code.intervalSec) * 1000;
26
34
  while (Date.now() < deadline) {