@quantiya/codevibe-antigravity-plugin 2.0.10 → 2.0.11

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 (2) hide show
  1. package/dist/server.js +257 -42
  2. package/package.json +2 -2
package/dist/server.js CHANGED
@@ -1121,13 +1121,16 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1121
1121
  if (kind === "question") {
1122
1122
  const q = extractQuestionHeader(snapshot);
1123
1123
  headerText = q ? q.body : null;
1124
+ } else if (kind === "file_access") {
1125
+ const fileAccess = extractFileAccessPrompt(snapshot);
1126
+ headerText = fileAccess ? fileAccess.identity : null;
1124
1127
  } else if (kind === "approval") {
1125
1128
  headerText = extractHeader(snapshot);
1126
1129
  }
1127
1130
  }
1128
- return { active, headerText };
1131
+ return { active, headerText, probeSucceeded: true };
1129
1132
  } catch {
1130
- return { active: false, headerText: null };
1133
+ return { active: false, headerText: null, probeSucceeded: false };
1131
1134
  }
1132
1135
  }
1133
1136
  // ─── tmux pipe-pane plumbing ─────────────────────────────────────────────
@@ -1388,7 +1391,7 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1388
1391
  function looksLikePromptDelta(chunk) {
1389
1392
  return (
1390
1393
  // Approval UI signatures
1391
- /Requesting permission for:/i.test(chunk) || /Do you want to proceed\?/i.test(chunk) || /\besc to cancel\b/i.test(chunk) || /^\s*[1-9]\. (?:Yes|No)\b/im.test(chunk) || /\[(?:y\/n|Y\/n|y\/N)\]/.test(chunk) || /Question \d+\/\d+:/i.test(chunk) || /\benter Select\b/i.test(chunk)
1394
+ /Requesting permission for:/i.test(chunk) || /Do you want to proceed\?/i.test(chunk) || /\besc to cancel\b/i.test(chunk) || /^\s*[1-9]\. (?:Yes|No)\b/im.test(chunk) || /\[(?:y\/n|Y\/n|y\/N)\]/.test(chunk) || /Question \d+\/\d+:/i.test(chunk) || /\benter Select\b/i.test(chunk) || /^File access\s*$/im.test(chunk) || /Allow access to this file\?/i.test(chunk) || /No, deny access/i.test(chunk)
1392
1395
  );
1393
1396
  }
1394
1397
  function looksLikePromptSnapshot(snapshot) {
@@ -1402,8 +1405,31 @@ function looksLikePromptSnapshot(snapshot) {
1402
1405
  const hasQuestionOptions = /^[>\s]+[1-9]\. \S/im.test(recent);
1403
1406
  const hasQuestionFooter = /\benter Select\b/i.test(recent);
1404
1407
  const isQuestion = hasQuestionHeader && hasQuestionOptions && hasQuestionFooter;
1408
+ const hasFileAccessHeader = /^File access\s*$/im.test(recent);
1409
+ const hasFileAccessQuestion = /Allow access to this file\?/i.test(recent);
1410
+ const isFileAccess = hasFileAccessHeader && hasFileAccessQuestion && hasApprovalOptions && hasApprovalFooter;
1405
1411
  const legacyYN = /\[(?:y\/n|Y\/n|y\/N)\]/.test(recent) && /\b(?:apply|approve|allow|continue|proceed|run|execute|confirm)\b/i.test(recent);
1406
- return legacyYN || isApproval || isQuestion;
1412
+ return legacyYN || isApproval || isQuestion || isFileAccess;
1413
+ }
1414
+ function extractFileAccessPrompt(snapshot) {
1415
+ const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
1416
+ const lines = stripped.split("\n");
1417
+ let headingIndex = -1;
1418
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1419
+ if (/^\s*File access\s*$/i.test(lines[index])) {
1420
+ headingIndex = index;
1421
+ break;
1422
+ }
1423
+ }
1424
+ if (headingIndex < 0) return null;
1425
+ const block = lines.slice(headingIndex, headingIndex + 20);
1426
+ const questionIndex = block.findIndex((line) => /Allow access to this file\?/i.test(line));
1427
+ if (questionIndex < 0) return null;
1428
+ const readLine = block.slice(0, questionIndex + 1).find((line) => /^\s*Read:\s*\S/i.test(line));
1429
+ const identity = readLine?.replace(/^\s*Read:\s*/i, "").trim() || "non-workspace file access";
1430
+ const fullLine = block[questionIndex].trim();
1431
+ const body = block.slice(1, questionIndex).join("\n").trim();
1432
+ return { identity, fullLine, body };
1407
1433
  }
1408
1434
  function extractHeader(snapshot) {
1409
1435
  const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
@@ -1422,8 +1448,10 @@ function detectActivePromptKind(snapshot) {
1422
1448
  const lines = stripped.split("\n");
1423
1449
  const approvalRe = /Requesting permission for:/i;
1424
1450
  const questionRe = /Question \d+\/\d+:/i;
1451
+ const fileAccessRe = /^\s*File access\s*$/i;
1425
1452
  let lastApprovalLineIdx = -1;
1426
1453
  let lastQuestionLineIdx = -1;
1454
+ let lastFileAccessLineIdx = -1;
1427
1455
  for (let i = lines.length - 1; i >= 0; i--) {
1428
1456
  if (lastApprovalLineIdx < 0 && approvalRe.test(lines[i])) {
1429
1457
  lastApprovalLineIdx = i;
@@ -1431,10 +1459,15 @@ function detectActivePromptKind(snapshot) {
1431
1459
  if (lastQuestionLineIdx < 0 && questionRe.test(lines[i])) {
1432
1460
  lastQuestionLineIdx = i;
1433
1461
  }
1434
- if (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0) break;
1462
+ if (lastFileAccessLineIdx < 0 && fileAccessRe.test(lines[i])) {
1463
+ lastFileAccessLineIdx = i;
1464
+ }
1465
+ if (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0 && lastFileAccessLineIdx >= 0) break;
1435
1466
  }
1436
- if (lastApprovalLineIdx < 0 && lastQuestionLineIdx < 0) return null;
1437
- if (lastQuestionLineIdx > lastApprovalLineIdx) return "question";
1467
+ const latest = Math.max(lastApprovalLineIdx, lastQuestionLineIdx, lastFileAccessLineIdx);
1468
+ if (latest < 0) return null;
1469
+ if (latest === lastFileAccessLineIdx) return "file_access";
1470
+ if (latest === lastQuestionLineIdx) return "question";
1438
1471
  return "approval";
1439
1472
  }
1440
1473
  function extractQuestionHeader(snapshot) {
@@ -1468,10 +1501,17 @@ var import_events3 = require("events");
1468
1501
  var import_uuid = require("uuid");
1469
1502
 
1470
1503
  // src/prompt-parser.ts
1504
+ var import_crypto2 = require("crypto");
1471
1505
  function parseApprovalSnapshot(snapshot) {
1472
1506
  if (!snapshot) return null;
1473
1507
  const stripped = stripAnsi(snapshot);
1474
1508
  const kind = detectActivePromptKind(stripped);
1509
+ if (kind === "file_access") {
1510
+ const fileAccess = extractFileAccessPrompt(stripped);
1511
+ if (fileAccess) {
1512
+ return parseFileAccessSnapshot(stripped, snapshot, fileAccess);
1513
+ }
1514
+ }
1475
1515
  if (kind === "question") {
1476
1516
  const questionHeader = extractQuestionHeader(stripped);
1477
1517
  if (questionHeader) {
@@ -1480,6 +1520,49 @@ function parseApprovalSnapshot(snapshot) {
1480
1520
  }
1481
1521
  return parseApprovalUISnapshot(stripped, snapshot);
1482
1522
  }
1523
+ function buildApprovalSemanticKey(candidate) {
1524
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
1525
+ const payload = {
1526
+ kind: candidate.kind,
1527
+ headerText: normalize(candidate.headerText),
1528
+ body: normalize(candidate.body),
1529
+ options: candidate.options.map((option) => ({
1530
+ number: option.number,
1531
+ text: normalize(option.text)
1532
+ })),
1533
+ submitMap: Object.fromEntries(
1534
+ Object.entries(candidate.submitMap).sort(([left], [right]) => Number(left) - Number(right)).map(([number, keys]) => [number, [...keys]])
1535
+ )
1536
+ };
1537
+ return (0, import_crypto2.createHash)("sha256").update(JSON.stringify(payload)).digest("hex");
1538
+ }
1539
+ function hasNegativeApprovalOption(options) {
1540
+ return options.some((option) => /^(?:no|deny|reject|cancel)\b/i.test(option.text.trim()));
1541
+ }
1542
+ function parseFileAccessSnapshot(stripped, originalSnapshot, prompt) {
1543
+ const lines = stripped.split("\n");
1544
+ let questionIndex = -1;
1545
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1546
+ if (/Allow access to this file\?/i.test(lines[index])) {
1547
+ questionIndex = index;
1548
+ break;
1549
+ }
1550
+ }
1551
+ const belowQuestion = questionIndex >= 0 ? lines.slice(questionIndex + 1).join("\n") : stripped;
1552
+ const options = parseOptions(belowQuestion);
1553
+ if (options.length < 2 || !hasNegativeApprovalOption(options)) return null;
1554
+ return {
1555
+ kind: "approval",
1556
+ headerText: prompt.identity,
1557
+ fullHeaderLine: prompt.fullLine,
1558
+ command: void 0,
1559
+ filePath: prompt.identity === "non-workspace file access" ? void 0 : prompt.identity,
1560
+ options,
1561
+ submitMap: buildApprovalSubmitMap(options),
1562
+ body: prompt.body,
1563
+ paneHash: hashPromptSnapshot(originalSnapshot)
1564
+ };
1565
+ }
1483
1566
  function parseApprovalUISnapshot(stripped, originalSnapshot) {
1484
1567
  const headerText = extractHeader(stripped);
1485
1568
  if (!headerText) return null;
@@ -1494,7 +1577,7 @@ function parseApprovalUISnapshot(stripped, originalSnapshot) {
1494
1577
  }
1495
1578
  const belowHeader = headerIdx >= 0 ? lines.slice(headerIdx + 1).join("\n") : stripped;
1496
1579
  const options = parseOptions(belowHeader);
1497
- if (options.length < 2) return null;
1580
+ if (options.length < 2 || !hasNegativeApprovalOption(options)) return null;
1498
1581
  const submitMap = buildApprovalSubmitMap(options);
1499
1582
  const { command, filePath } = extractIdentity(headerText);
1500
1583
  const body = extractBodyBetweenHeaderAndFirstOption(belowHeader);
@@ -1785,9 +1868,10 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
1785
1868
  matchedPaneHeader: candidate.headerText,
1786
1869
  paneDisplayHeader: candidate.fullHeaderLine,
1787
1870
  body: candidate.body,
1871
+ paneOptions: candidate.options,
1872
+ paneSemanticKey: buildApprovalSemanticKey(candidate),
1788
1873
  emittedAt: Date.now(),
1789
1874
  ttlMs: this.promptTtlMs,
1790
- paneOptions: candidate.options,
1791
1875
  // E1 (§6a-2b / F5) — carry the consumed pane hash so an emit-failure
1792
1876
  // rollback can reset the observer's lastPromptHash and let the unchanged
1793
1877
  // live pane re-emit.
@@ -1974,6 +2058,8 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
1974
2058
  matchedPaneHeader: candidate.headerText,
1975
2059
  paneDisplayHeader: candidate.fullHeaderLine,
1976
2060
  body: candidate.body,
2061
+ paneOptions: candidate.options,
2062
+ paneSemanticKey: buildApprovalSemanticKey(candidate),
1977
2063
  emittedAt: Date.now(),
1978
2064
  ttlMs: this.promptTtlMs
1979
2065
  };
@@ -2054,6 +2140,10 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
2054
2140
  getPendingPrompt(promptId) {
2055
2141
  return this.pendingPrompts.get(promptId) ?? null;
2056
2142
  }
2143
+ getPendingPromptsForConversation(conversationId) {
2144
+ const pending = Array.from(this.pendingPrompts.values());
2145
+ return conversationId ? pending.filter((state) => state.conversationId === conversationId) : pending;
2146
+ }
2057
2147
  // ─── Read-only accessors ────────────────────────────────────────────────
2058
2148
  getPendingCalls() {
2059
2149
  return Array.from(this.pendingCalls.values());
@@ -2107,21 +2197,6 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
2107
2197
  this.emit("pending-call-expired", call);
2108
2198
  }
2109
2199
  }
2110
- let anyExpired = false;
2111
- for (const [promptId, state] of this.pendingPrompts.entries()) {
2112
- if (now - state.emittedAt > state.ttlMs) {
2113
- this.pendingPrompts.delete(promptId);
2114
- this.resolvedPrompts.set(promptId, now);
2115
- anyExpired = true;
2116
- logger.debug("Pending prompt expired (TTL)", {
2117
- promptId,
2118
- ageMs: now - state.emittedAt
2119
- });
2120
- }
2121
- }
2122
- if (anyExpired) {
2123
- this.paneOnlyEmittedHashes.clear();
2124
- }
2125
2200
  for (const [promptId, resolvedAt] of this.resolvedPrompts.entries()) {
2126
2201
  if (now - resolvedAt > 5 * 6e4) {
2127
2202
  this.resolvedPrompts.delete(promptId);
@@ -2228,22 +2303,11 @@ var PromptResponder = class {
2228
2303
  };
2229
2304
  }
2230
2305
  if (this.paneObserver) {
2231
- const probe = await this.paneObserver.probeApprovalUIActive();
2232
- if (!probe.active) {
2233
- this.detector.resolvePrompt(promptId);
2234
- return {
2235
- ok: false,
2236
- reason: "prompt-superseded",
2237
- details: "approval UI vanished from pane (likely user approved on desktop)"
2238
- };
2239
- }
2240
- const expectedHeader = state.matchedPaneHeader;
2241
- if (expectedHeader && probe.headerText && !sameIdentity(probe.headerText, expectedHeader)) {
2242
- this.detector.resolvePrompt(promptId);
2306
+ if (!state.paneSemanticKey) {
2243
2307
  return {
2244
2308
  ok: false,
2245
- reason: "prompt-superseded",
2246
- details: `pane shows '${probe.headerText}' but matched-against header was '${expectedHeader}'`
2309
+ reason: "prompt-probe-failed",
2310
+ details: "pending prompt has no exact semantic identity"
2247
2311
  };
2248
2312
  }
2249
2313
  }
@@ -2257,6 +2321,51 @@ var PromptResponder = class {
2257
2321
  }
2258
2322
  try {
2259
2323
  for (const key of keys) {
2324
+ if (!this.detector || !this.paneObserver) {
2325
+ return {
2326
+ ok: false,
2327
+ reason: "prompt-probe-failed",
2328
+ details: "approval responder is missing its detector or pane observer"
2329
+ };
2330
+ }
2331
+ if (this.detector.getPendingPrompt(promptId) !== state) {
2332
+ return { ok: false, reason: "prompt-expired" };
2333
+ }
2334
+ let liveSemanticKey = null;
2335
+ try {
2336
+ const snapshot = await this.paneObserver.captureSnapshot();
2337
+ const live = parseApprovalSnapshot(snapshot);
2338
+ if (!live) {
2339
+ if (detectActivePromptKind(snapshot) === null) {
2340
+ this.detector.resolvePrompt(promptId);
2341
+ return {
2342
+ ok: false,
2343
+ reason: "prompt-superseded",
2344
+ details: "the approval chooser is no longer active"
2345
+ };
2346
+ }
2347
+ return {
2348
+ ok: false,
2349
+ reason: "prompt-probe-failed",
2350
+ details: "the active chooser could not be parsed exactly"
2351
+ };
2352
+ }
2353
+ liveSemanticKey = live ? buildApprovalSemanticKey(live) : null;
2354
+ } catch (error) {
2355
+ return {
2356
+ ok: false,
2357
+ reason: "prompt-probe-failed",
2358
+ details: error instanceof Error ? error.message : String(error)
2359
+ };
2360
+ }
2361
+ if (liveSemanticKey !== state.paneSemanticKey) {
2362
+ this.detector.resolvePrompt(promptId);
2363
+ return {
2364
+ ok: false,
2365
+ reason: "prompt-superseded",
2366
+ details: "active chooser semantics differ from the mobile prompt"
2367
+ };
2368
+ }
2260
2369
  if (isNamedKey(key)) {
2261
2370
  await this.sendKey(target, key);
2262
2371
  } else {
@@ -2289,6 +2398,43 @@ var PromptResponder = class {
2289
2398
  };
2290
2399
  }
2291
2400
  }
2401
+ async sendFreeFormWhenNoApprovalActive(text) {
2402
+ const target = this.resolveTmuxTarget();
2403
+ if (!target) return { ok: false, reason: "no-tmux-target" };
2404
+ if (!this.paneObserver) {
2405
+ return {
2406
+ ok: false,
2407
+ reason: "prompt-probe-failed",
2408
+ details: "pane observer is required for safe free-form input"
2409
+ };
2410
+ }
2411
+ const chooserIsAbsent = async () => {
2412
+ const probe = await this.paneObserver.probeApprovalUIActive();
2413
+ if (!probe.probeSucceeded) {
2414
+ return { ok: false, reason: "prompt-probe-failed" };
2415
+ }
2416
+ if (probe.active) {
2417
+ return { ok: false, reason: "prompt-superseded" };
2418
+ }
2419
+ return null;
2420
+ };
2421
+ try {
2422
+ const beforeText = await chooserIsAbsent();
2423
+ if (beforeText) return beforeText;
2424
+ await this.typeLiteral(target, text);
2425
+ await delay(ENTER_DELAY_MS);
2426
+ const beforeEnter = await chooserIsAbsent();
2427
+ if (beforeEnter) return beforeEnter;
2428
+ await this.sendKey(target, "Enter");
2429
+ return { ok: true };
2430
+ } catch (error) {
2431
+ return {
2432
+ ok: false,
2433
+ reason: "tmux-failed",
2434
+ details: error instanceof Error ? error.message : String(error)
2435
+ };
2436
+ }
2437
+ }
2292
2438
  // ─── Tmux primitives ────────────────────────────────────────────────────
2293
2439
  async typeLiteral(target, text) {
2294
2440
  const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
@@ -2314,9 +2460,6 @@ function isNamedKey(key) {
2314
2460
  function delay(ms) {
2315
2461
  return new Promise((r) => setTimeout(r, ms));
2316
2462
  }
2317
- function sameIdentity(headerFromPane, expected) {
2318
- return headerFromPane.trim() === expected.trim();
2319
- }
2320
2463
 
2321
2464
  // src/mobile-prompt-dedupe.ts
2322
2465
  var DEFAULT_EXPIRY_MS = 15e3;
@@ -4242,8 +4385,63 @@ var McpServer = class _McpServer {
4242
4385
  });
4243
4386
  }
4244
4387
  }
4388
+ const pendingPrompts = this.approvalDetector.getPendingPromptsForConversation(
4389
+ session.conversationId
4390
+ );
4391
+ if (pendingPrompts.length === 0) {
4392
+ const probe = await this.paneObserver.probeApprovalUIActive();
4393
+ if (!probe.probeSucceeded) {
4394
+ await this.emitPromptSafetyNotification(
4395
+ session,
4396
+ "Your message was not sent because the desktop approval state could not be verified. Try again or resolve the prompt on the desktop."
4397
+ );
4398
+ return;
4399
+ }
4400
+ if (probe.active) {
4401
+ await this.emitPromptSafetyNotification(
4402
+ session,
4403
+ "Your message was not sent because a desktop approval is active but its options are not yet available on mobile. Resolve it on the desktop and send the message again."
4404
+ );
4405
+ return;
4406
+ }
4407
+ }
4408
+ if (pendingPrompts.length > 0) {
4409
+ if (pendingPrompts.length !== 1) {
4410
+ await this.emitPromptSafetyNotification(
4411
+ session,
4412
+ "Your message was not sent because more than one desktop approval is pending. Resolve the approvals and send it again."
4413
+ );
4414
+ return;
4415
+ }
4416
+ const activePrompt = pendingPrompts[0];
4417
+ const rejectOption = activePrompt.paneOptions?.find(
4418
+ (option) => /^(?:no|deny|reject|cancel)\b/i.test(option.text.trim()) && (activePrompt.submitMap[option.number]?.length ?? 0) > 0
4419
+ );
4420
+ if (!rejectOption) {
4421
+ await this.emitPromptSafetyNotification(
4422
+ session,
4423
+ "Your message was not sent because the active desktop approval has no verified reject option. Resolve it on the desktop and send the message again."
4424
+ );
4425
+ return;
4426
+ }
4427
+ this.mobileDeduper.track(session.sessionId, rejectOption.number);
4428
+ const rejectResult = await this.promptResponder.sendApprovalReply(
4429
+ activePrompt.promptId,
4430
+ rejectOption.number
4431
+ );
4432
+ if (!rejectResult.ok) {
4433
+ this.mobileDeduper.forget(session.sessionId, rejectOption.number);
4434
+ logger.warn("Reject-before-free-form failed", {
4435
+ promptId: activePrompt.promptId,
4436
+ reason: rejectResult.reason,
4437
+ details: rejectResult.details
4438
+ });
4439
+ return;
4440
+ }
4441
+ await new Promise((resolve3) => setTimeout(resolve3, 250));
4442
+ }
4245
4443
  this.mobileDeduper.track(session.sessionId, promptContent);
4246
- const result = await this.promptResponder.sendFreeForm(promptContent);
4444
+ const result = await this.promptResponder.sendFreeFormWhenNoApprovalActive(promptContent);
4247
4445
  if (!result.ok) {
4248
4446
  this.mobileDeduper.forget(session.sessionId, promptContent);
4249
4447
  logger.warn("sendFreeForm failed", { reason: result.reason, details: result.details });
@@ -4278,6 +4476,23 @@ var McpServer = class _McpServer {
4278
4476
  await this.markExecuted(evt);
4279
4477
  }
4280
4478
  }
4479
+ async emitPromptSafetyNotification(session, content) {
4480
+ const input = {
4481
+ sessionId: session.sessionId,
4482
+ type: import_codevibe_core4.EventType.NOTIFICATION,
4483
+ source: import_codevibe_core4.EventSource.DESKTOP,
4484
+ content,
4485
+ metadata: { promptSafetyBlocked: true },
4486
+ timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: session.sessionId })
4487
+ };
4488
+ const outbound = this.encryptOutbound(session, input);
4489
+ if (!outbound) return;
4490
+ try {
4491
+ await this.appSyncClient.createEvent(outbound);
4492
+ } catch (error) {
4493
+ logger.warn("Failed to emit prompt-safety notification", { error: String(error) });
4494
+ }
4495
+ }
4281
4496
  /**
4282
4497
  * Transition a mobile event's deliveryStatus to DELIVERED. Called as
4283
4498
  * soon as the plugin receives + decrypts the event and BEFORE the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantiya/codevibe-antigravity-plugin",
3
- "version": "2.0.10",
3
+ "version": "2.0.11",
4
4
  "description": "Control Antigravity CLI from your iPhone and Android — real-time sync, approve file edits, send prompts by voice. Part of CodeVibe.",
5
5
  "main": "dist/server.js",
6
6
  "codevibe": {
@@ -48,7 +48,7 @@
48
48
  "node": ">=22.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@quantiya/codevibe-core": "2.0.9",
51
+ "@quantiya/codevibe-core": "2.0.10",
52
52
  "chokidar": "^5.0.0",
53
53
  "dotenv": "^16.6.1",
54
54
  "express": "^5.1.0",