@agent-native/core 0.0.0-beta-20260820150521 → 0.0.0-beta-20260820171000

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 (30) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/changelog/2026-08-20-staged-data-limit-recovery.md +5 -0
  3. package/corpus/templates/clips/changelog/2026-08-20-cross-tab-recording-controls.md +5 -0
  4. package/corpus/templates/clips/changelog/2026-08-20-overlay-follow-review-fixes.md +5 -0
  5. package/corpus/templates/clips/changelog/2026-08-20-provider-key-recovery.md +5 -0
  6. package/corpus/templates/clips/chrome-extension/PERMISSIONS.md +29 -46
  7. package/corpus/templates/clips/chrome-extension/STORE_DRAFT.md +1 -1
  8. package/corpus/templates/clips/chrome-extension/public/manifest.json +9 -2
  9. package/corpus/templates/clips/chrome-extension/src/background.ts +65 -46
  10. package/corpus/templates/clips/chrome-extension/src/content-script.ts +25 -17
  11. package/corpus/templates/clips/chrome-extension/src/overlay-follow.ts +25 -0
  12. package/corpus/templates/dispatch/changelog/2026-08-20-dispatch-chat-loads-openrouter-provider-reliably.md +6 -0
  13. package/corpus/templates/dispatch/package.json +2 -0
  14. package/corpus/templates/macros/netlify.toml +2 -0
  15. package/dist/client/chat/run-recovery.js +8 -3
  16. package/dist/client/integrations/IntegrationsPanel.js +12 -1
  17. package/dist/collab/routes.d.ts +1 -1
  18. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  19. package/dist/mcp-client/oauth-routes.d.ts +1 -0
  20. package/dist/mcp-client/oauth-routes.js +37 -9
  21. package/dist/mcp-client/remote-store.d.ts +12 -0
  22. package/dist/mcp-client/remote-store.js +71 -0
  23. package/dist/notifications/routes.d.ts +3 -3
  24. package/dist/observability/routes.d.ts +3 -3
  25. package/dist/provider-api/actions/provider-api.js +16 -4
  26. package/dist/resources/handlers.d.ts +2 -2
  27. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  28. package/dist/server/realtime-token.d.ts +1 -1
  29. package/dist/server/transcribe-voice.d.ts +1 -1
  30. package/package.json +2 -2
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8484
34
+ - template files: 8490
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-20
4
+ ---
5
+ Analytics now explains how to recover when staged data reaches its size limit.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-20
4
+ ---
5
+ Recording controls now follow you across browser tabs and page navigations.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-20
4
+ ---
5
+ Clips overlays now follow tab switches during the recording countdown and recover cleanly on tabs opened before Clips.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-20
4
+ ---
5
+ Clips now opens provider setup instead of retrying a rejected key in a loop.
@@ -1,41 +1,45 @@
1
1
  # Permissions model
2
2
 
3
- This extension ships **activeTab-only for recording overlays**: it deliberately
4
- does **not** request the broad `<all_urls>` host permission for recording UI.
3
+ This extension ships **cross-tab follow for recording overlays**: it declares
4
+ the broad `<all_urls>` host permission and a matching declarative content script
5
+ so the camera bubble and controls can survive tab switches and navigations.
5
6
 
6
7
  ## Why
7
8
 
8
9
  A declarative content script on `<all_urls>` (and the matching `<all_urls>` host
9
10
  permission) triggers Chrome Web Store's **broad host permission in-depth review**,
10
- which significantly delays publishing and updates. We avoid that.
11
+ which significantly delays publishing and updates. We take that review path only
12
+ for the release that re-enables cross-tab follow.
11
13
 
12
- The only declarative content script is scoped to `https://github.com/*`. It
13
- looks for Clips links in GitHub issue/PR markdown and replaces them with a
14
- playable preview iframe owned by the extension. That adds a narrow GitHub host
15
- disclosure, but does not grant broad access to every page.
14
+ The declarative content scripts are scoped to:
15
+
16
+ - `https://github.com/*` for Clips link previews in GitHub issue/PR markdown.
17
+ - `<all_urls>` for the recording overlay host that keeps the face bubble and
18
+ controls mounted across pages while a recording is active.
19
+
20
+ The GitHub preview script adds a narrow host disclosure, while the overlay host
21
+ is the broad review boundary.
16
22
 
17
23
  ## How the overlay gets on the page
18
24
 
19
25
  When the user clicks the extension and starts a recording, the background service
20
- worker injects the content script into the **launch tab** with
21
- `chrome.scripting.executeScript({ target: { tabId }, files: ["assets/content-script.js"] })`.
22
- That call is authorized by the **`activeTab`** permission, which Chrome grants for
23
- the tab that was active when the user invoked the extension no broad host access
24
- needed. The content script then mounts the overlay iframes (countdown, camera
25
- bubble, controls). `web_accessible_resources` stays `<all_urls>` — that is **not**
26
- a host permission and does not trigger the review.
26
+ worker mounts the overlay on the active tab and keeps rebroadcasting it as the
27
+ active tab changes. The content script then mounts the overlay iframes
28
+ (countdown, camera bubble, controls). `web_accessible_resources` stays
29
+ `<all_urls>` that is **not** a host permission and does not trigger the review.
27
30
 
28
31
  Declared permissions: `activeTab`, `debugger`, `offscreen`, `scripting`,
29
- `storage`. Host permissions: only the configured Clips app + `forms.agent-native.com`
32
+ `storage`. Host permissions: the configured Clips app + `forms.agent-native.com`
30
33
 
31
34
  - `localhost`/`127.0.0.1`.
32
- - `https://github.com/*` is content-script scoped for link previews only.
35
+ - `https://github.com/*` is content-script scoped for link previews.
36
+ - `<all_urls>` is the overlay host required for cross-tab follow.
33
37
 
34
38
  ## What this costs
35
39
 
36
- - The overlay (countdown, camera bubble, recording controls) lives **only on the
37
- tab the recording was launched from**. It does **not** follow the user to other
38
- tabs during a full-screen or multi-tab recording.
40
+ - The overlay (countdown, camera bubble, recording controls) follows the user
41
+ across tabs while a recording is active. Navigating to a new page or tab keeps
42
+ the face bubble and controls mounted once the page loads.
39
43
  - **Recording itself is unaffected.** Capture runs in the offscreen document via
40
44
  `getDisplayMedia`, independent of any tab's content script — full-screen,
41
45
  window, and other-tab content are all still captured normally. Only the on-page
@@ -43,36 +47,15 @@ Declared permissions: `activeTab`, `debugger`, `offscreen`, `scripting`,
43
47
 
44
48
  ## Re-enabling cross-tab follow
45
49
 
46
- The full cross-tab behavior is gated behind a single flag in
47
- `src/background.ts`:
50
+ The full cross-tab behavior is gated behind a single flag in `src/background.ts`:
48
51
 
49
52
  ```ts
50
- const CROSS_TAB_FOLLOW = false; // flip to true
53
+ const CROSS_TAB_FOLLOW = true;
51
54
  ```
52
55
 
53
- Flipping it to `true` restores the all-tabs broadcast in `broadcastMount()` /
54
- `broadcastUnmount()` and the `chrome.tabs.onActivated` follow listener. To make
55
- that actually work you must **also** restore broad host access so the worker can
56
- inject into arbitrary tabs. Two options:
57
-
58
- 1. **Static (simplest, but in-depth review):** re-add to `public/manifest.json`:
59
- - `"<all_urls>"` in `host_permissions`, and
60
- - the `content_scripts` block:
61
- ```json
62
- "content_scripts": [
63
- {
64
- "matches": ["<all_urls>"],
65
- "js": ["assets/content-script.js"],
66
- "run_at": "document_idle",
67
- "all_frames": false
68
- }
69
- ],
70
- ```
71
-
72
- 2. **Optional (preferred — keeps the default install lean):** declare
73
- `"optional_host_permissions": ["<all_urls>"]` in the manifest and call
74
- `chrome.permissions.request({ origins: ["<all_urls>"] })` at runtime (e.g. from
75
- a settings toggle) before enabling follow. The user grants the broad access
76
- explicitly, only if they want cross-tab overlays.
56
+ That keeps the all-tabs broadcast in `broadcastMount()` / `broadcastUnmount()`
57
+ and the `chrome.tabs.onActivated` follow listener live. The manifest must keep
58
+ the broad host permission and the `<all_urls>` declarative content script in
59
+ place so the worker can inject into arbitrary tabs.
77
60
 
78
61
  After any change: `pnpm build`, then re-zip `dist/`.
@@ -56,7 +56,7 @@ Upload the generated artifact (the version matches `public/manifest.json`; the
56
56
  Chrome Web Store requires each upload to use a higher version than the last):
57
57
 
58
58
  ```txt
59
- templates/clips/chrome-extension/releases/clips-chrome-extension-0.1.18.zip
59
+ templates/clips/chrome-extension/releases/clips-chrome-extension-0.1.19.zip
60
60
  ```
61
61
 
62
62
  ## Web App Rollout Gate
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Agent-Native Clips",
4
4
  "short_name": "Clips",
5
- "version": "0.1.18",
5
+ "version": "0.1.19",
6
6
  "description": "Start Clips recordings from Chrome, capture optional diagnostics, and preview Clips links on GitHub and Jira.",
7
7
  "minimum_chrome_version": "116",
8
8
  "action": {
@@ -31,7 +31,8 @@
31
31
  "https://clips.agent-native.com/*",
32
32
  "https://forms.agent-native.com/*",
33
33
  "http://localhost/*",
34
- "http://127.0.0.1/*"
34
+ "http://127.0.0.1/*",
35
+ "<all_urls>"
35
36
  ],
36
37
  "content_scripts": [
37
38
  {
@@ -43,6 +44,12 @@
43
44
  "js": ["assets/github-preview-content.js"],
44
45
  "run_at": "document_idle",
45
46
  "all_frames": false
47
+ },
48
+ {
49
+ "matches": ["<all_urls>"],
50
+ "js": ["assets/content-script.js"],
51
+ "run_at": "document_idle",
52
+ "all_frames": false
46
53
  }
47
54
  ],
48
55
  "web_accessible_resources": [
@@ -6,6 +6,10 @@ import {
6
6
  shouldReconcilePersistedRecording,
7
7
  type OffscreenRecordingState,
8
8
  } from "./native-recording-state";
9
+ import {
10
+ sendWithInjectionFallback,
11
+ shouldFollowOverlay,
12
+ } from "./overlay-follow";
9
13
  import { captureExtensionError, initExtensionSentry } from "./sentry";
10
14
 
11
15
  initExtensionSentry("background");
@@ -276,16 +280,11 @@ let overlayBaseEpochMs = 0;
276
280
  // up, for both camera-only AND screen+camera recording (recordingShowsBubble).
277
281
  let overlayShowsBubble = false;
278
282
  let recordingShowsBubble = false;
279
- // Cross-tab follow: when true, the overlay is pushed to whatever tab the user
280
- // switches to mid-recording. That requires "<all_urls>" + a declarative content
281
- // script, which triggers Chrome's broad-host in-depth review. Shipped OFF
282
- // (activeTab-only: the overlay lives on the launch tab the user invoked the
283
- // extension from). Capture is unaffected it runs in the offscreen document via
284
- // getDisplayMedia regardless. See PERMISSIONS.md to re-enable.
285
- // Typed `boolean` (not inferred literal `false`) so the cross-tab branches below
286
- // don't read as unreachable code; flip the value to re-enable. See PERMISSIONS.md.
287
- const CROSS_TAB_FOLLOW: boolean = false;
288
- // The tab the recording was launched from — the only tab activeTab lets us touch.
283
+ // Cross-tab follow stays enabled for the next release. It needs the manifest's
284
+ // broad-host review path (`<all_urls>` + declarative content script coverage) so
285
+ // the face bubble and controls keep following the user across tabs and reloads.
286
+ const CROSS_TAB_FOLLOW: boolean = true;
287
+ // The launch tab stays the anchor for the explicit reload recovery path.
289
288
  let overlayTabId: number | null = null;
290
289
  let countdownEndsAtMs = 0;
291
290
  let armingNativeRecordingSessionId: string | null = null;
@@ -438,20 +437,19 @@ async function restoreRuntimeState(): Promise<void> {
438
437
  function sendTabMessage(
439
438
  tabId: number,
440
439
  message: Record<string, unknown>,
441
- ): Promise<void> {
440
+ ): Promise<boolean> {
442
441
  return new Promise((resolve) => {
443
442
  try {
444
443
  chrome.tabs.sendMessage(tabId, message, () => {
445
- void chrome.runtime.lastError;
446
- resolve();
444
+ resolve(!chrome.runtime.lastError);
447
445
  });
448
446
  } catch {
449
- resolve();
447
+ resolve(false);
450
448
  }
451
449
  });
452
450
  }
453
451
 
454
- async function ensureContentScript(tabId: number): Promise<void> {
452
+ async function injectContentScript(tabId: number): Promise<boolean> {
455
453
  const scripting = (
456
454
  chrome as typeof chrome & {
457
455
  scripting?: {
@@ -462,18 +460,32 @@ async function ensureContentScript(tabId: number): Promise<void> {
462
460
  };
463
461
  }
464
462
  ).scripting;
465
- if (!scripting) return;
466
- await scripting
467
- .executeScript({ target: { tabId }, files: ["assets/content-script.js"] })
468
- .catch(() => undefined);
463
+ if (!scripting) return false;
464
+ try {
465
+ await scripting.executeScript({
466
+ target: { tabId },
467
+ files: ["assets/content-script.js"],
468
+ });
469
+ return true;
470
+ } catch {
471
+ // Unsupported pages (chrome://, the Chrome Web Store, and similar) reject
472
+ // injection. They still need to record without an in-page overlay.
473
+ return false;
474
+ }
469
475
  }
470
476
 
471
- async function mountOverlayOnTab(tabId: number): Promise<void> {
472
- await ensureContentScript(tabId);
473
- await sendTabMessage(tabId, {
474
- type: "CLIPS_OVERLAY_MOUNT",
475
- parts: desiredParts(),
476
- });
477
+ async function mountOverlayOnTab(
478
+ tabId: number,
479
+ parts: OverlayPart[] = desiredParts(),
480
+ ): Promise<boolean> {
481
+ return sendWithInjectionFallback(
482
+ () =>
483
+ sendTabMessage(tabId, {
484
+ type: "CLIPS_OVERLAY_MOUNT",
485
+ parts,
486
+ }),
487
+ () => injectContentScript(tabId),
488
+ );
477
489
  }
478
490
 
479
491
  function allTabs(): Promise<chrome.tabs.Tab[]> {
@@ -483,9 +495,8 @@ function allTabs(): Promise<chrome.tabs.Tab[]> {
483
495
  }
484
496
 
485
497
  async function broadcastMount(): Promise<void> {
486
- // activeTab-only: keep the overlay on the launch tab (re-ensures the injected
487
- // content script too, so it survives a worker suspend). Cross-tab broadcast is
488
- // gated behind CROSS_TAB_FOLLOW because it needs broad host access.
498
+ // Cross-tab follow uses the declarative content script path, so every mounted
499
+ // tab can receive the current overlay parts and survive reloads.
489
500
  if (!CROSS_TAB_FOLLOW) {
490
501
  if (overlayTabId !== null) await mountOverlayOnTab(overlayTabId);
491
502
  return;
@@ -548,12 +559,9 @@ async function startPreview(wantsCamera: boolean): Promise<void> {
548
559
  if (!tab || typeof tab.id !== "number") return;
549
560
  if (previewTabId !== null && previewTabId !== tab.id) await stopPreview();
550
561
  previewTabId = tab.id;
551
- await ensureContentScript(tab.id);
552
- // Injection / send fail silently on unsupported pages (chrome://, etc.).
553
- await sendTabMessage(tab.id, {
554
- type: "CLIPS_OVERLAY_MOUNT",
555
- parts: ["bubble"],
556
- });
562
+ // Message delivery reuses the declarative script; fallback injection fails
563
+ // silently on unsupported pages (chrome://, the Web Store, and similar).
564
+ await mountOverlayOnTab(tab.id, ["bubble"]);
557
565
  }
558
566
 
559
567
  async function stopPreview(): Promise<void> {
@@ -2560,29 +2568,40 @@ async function dispatchRuntimeMessage(message: unknown): Promise<unknown> {
2560
2568
  }
2561
2569
  }
2562
2570
 
2563
- // While a recording is active, keep the overlay following the user as they
2564
- // switch tabs (programmatic injection covers tabs opened before the extension
2565
- // loaded; declared content scripts cover navigations).
2571
+ // While a recording is active or arming, keep the overlay following the user as
2572
+ // they switch tabs. The message-first path reuses declarative scripts and only
2573
+ // injects for tabs that predate the extension or otherwise have no receiver.
2566
2574
  chrome.tabs.onActivated.addListener((info) => {
2567
- if (!CROSS_TAB_FOLLOW) return; // activeTab-only: overlay stays on the launch tab
2575
+ if (!CROSS_TAB_FOLLOW) return;
2568
2576
  void (async () => {
2569
2577
  await ensureRestored();
2570
- if (overlayPhase === "idle" || !activeNativeRecording) return;
2578
+ if (
2579
+ !shouldFollowOverlay(
2580
+ overlayPhase,
2581
+ Boolean(activeNativeRecording),
2582
+ Boolean(armingNativeRecordingSessionId),
2583
+ )
2584
+ )
2585
+ return;
2571
2586
  await mountOverlayOnTab(info.tabId);
2572
2587
  })();
2573
2588
  });
2574
2589
 
2575
- // The overlay content script is injected on demand (activeTab), so reloading the
2576
- // launch tab wipes it the camera bubble and toolbar would vanish for the rest
2577
- // of the recording even though capture keeps running in the offscreen document.
2578
- // Re-inject and re-mount once the launch tab finishes reloading while a recording
2579
- // is active. Scoped to the launch tab (the only tab activeTab lets us touch); a
2580
- // same-URL reload keeps that grant.
2590
+ // Keep the launch tab in sync after a reload. Declarative follow covers the
2591
+ // other tabs; this is the explicit recovery path for the tab that originally
2592
+ // started the recording.
2581
2593
  chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
2582
2594
  if (changeInfo.status !== "complete") return;
2583
2595
  void (async () => {
2584
2596
  await ensureRestored();
2585
- if (overlayPhase === "idle" || !activeNativeRecording) return;
2597
+ if (
2598
+ !shouldFollowOverlay(
2599
+ overlayPhase,
2600
+ Boolean(activeNativeRecording),
2601
+ Boolean(armingNativeRecordingSessionId),
2602
+ )
2603
+ )
2604
+ return;
2586
2605
  if (tabId !== overlayTabId) return;
2587
2606
  await mountOverlayOnTab(tabId);
2588
2607
  broadcastOverlayState();
@@ -1,12 +1,10 @@
1
- // Loom-style in-page overlay host. The background service worker injects this
2
- // into the LAUNCH TAB on demand via chrome.scripting.executeScript (covered by
3
- // the activeTab permission the user grants when they click the extension), NOT
4
- // declaratively on every page — that would need broad "<all_urls>" host access
5
- // and Chrome's in-depth review. So the overlay lives on the tab the recording
6
- // was started from; it does not follow across tabs unless CROSS_TAB_FOLLOW is
7
- // re-enabled in background.ts (see PERMISSIONS.md). Wrapped in an IIFE so it
8
- // emits a single self-contained classic script with no module imports/exports
9
- // and leaks no names into the shared global scope. Its only job is to
1
+ // Loom-style in-page overlay host. The next release declares this on every page
2
+ // so the face bubble and controls can survive tab switches and navigations; the
3
+ // background worker can also inject it into the launch tab for immediate and
4
+ // reload recovery. That broad "<all_urls>" host access is the Chrome Web Store
5
+ // in-depth review boundary. Wrapped in an IIFE so it emits a single self-contained
6
+ // classic script with no module imports/exports and leaks no names into the shared
7
+ // global scope. Its only job is to
10
8
  // mount/unmount the overlay iframes; all UI and control logic lives inside the
11
9
  // extension-origin overlay pages (src/overlay.html). The worker is the source of
12
10
  // truth for which "parts" are visible and pushes them here.
@@ -18,6 +16,12 @@
18
16
  const ALL_PARTS: OverlayPart[] = ["bubble", "countdown", "toolbar", "saving"];
19
17
  const flags = window as unknown as { __clipsOverlayHostReady?: boolean };
20
18
 
19
+ // Declarative and recovery injection can target the same document. Return
20
+ // before any side effects so global listeners remain one-per-page.
21
+ if (flags.__clipsOverlayHostReady) return;
22
+ flags.__clipsOverlayHostReady = true;
23
+ let recordingActive = false;
24
+
21
25
  function errorPayload(error: unknown): {
22
26
  name: string;
23
27
  message: string;
@@ -40,6 +44,7 @@
40
44
  error: unknown,
41
45
  context: Record<string, unknown> = {},
42
46
  ): void {
47
+ if (!recordingActive) return;
43
48
  try {
44
49
  chrome.runtime.sendMessage(
45
50
  {
@@ -73,6 +78,15 @@
73
78
  });
74
79
  });
75
80
 
81
+ try {
82
+ chrome.storage.onChanged.addListener((changes, areaName) => {
83
+ if (areaName !== "local" || !changes.clipsRecordingActive) return;
84
+ recordingActive = changes.clipsRecordingActive.newValue === true;
85
+ });
86
+ } catch {
87
+ /* storage unavailable */
88
+ }
89
+
76
90
  // ----- Draggable, resizable camera bubble ---------------------------------
77
91
  // Size + position persist in storage so the bubble stays where the user put it
78
92
  // across pages and recordings (like the desktop app). The iframe can't move or
@@ -339,7 +353,8 @@
339
353
  try {
340
354
  chrome.storage.local.get("clipsRecordingActive", (value) => {
341
355
  if (chrome.runtime.lastError) return;
342
- if (value && value.clipsRecordingActive) requestState();
356
+ recordingActive = value?.clipsRecordingActive === true;
357
+ if (recordingActive) requestState();
343
358
  });
344
359
  } catch {
345
360
  /* ignore */
@@ -593,13 +608,6 @@
593
608
  lastWantedParts = wanted;
594
609
  }
595
610
 
596
- // Guard against rare double-injection (SPA soft-reloads re-running the script).
597
- if (flags.__clipsOverlayHostReady) {
598
- syncIfRecording();
599
- return;
600
- }
601
- flags.__clipsOverlayHostReady = true;
602
-
603
611
  chrome.runtime.onMessage.addListener((message) => {
604
612
  if (!message || typeof message !== "object") return;
605
613
  const type = (message as { type?: unknown }).type;
@@ -0,0 +1,25 @@
1
+ export type OverlayFollowPhase =
2
+ | "idle"
3
+ | "countdown"
4
+ | "recording"
5
+ | "paused"
6
+ | "saving";
7
+
8
+ export function shouldFollowOverlay(
9
+ phase: OverlayFollowPhase,
10
+ hasActiveNativeRecording: boolean,
11
+ hasArmingNativeRecording: boolean,
12
+ ): boolean {
13
+ return (
14
+ phase !== "idle" && (hasActiveNativeRecording || hasArmingNativeRecording)
15
+ );
16
+ }
17
+
18
+ export async function sendWithInjectionFallback(
19
+ sendMessage: () => Promise<boolean>,
20
+ injectContentScript: () => Promise<boolean>,
21
+ ): Promise<boolean> {
22
+ if (await sendMessage()) return true;
23
+ if (!(await injectContentScript())) return false;
24
+ return sendMessage();
25
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-20
4
+ ---
5
+
6
+ Dispatch chat now loads its OpenRouter provider reliably in production.
@@ -19,9 +19,11 @@
19
19
  "@agent-native/toolkit": "workspace:*",
20
20
  "@fontsource-variable/inter": "^5.2.8",
21
21
  "@libsql/client": "^0.15.8",
22
+ "@openrouter/ai-sdk-provider": "^2.9.1",
22
23
  "@radix-ui/react-tooltip": "^1.2.7",
23
24
  "@tabler/icons-react": "catalog:",
24
25
  "@tanstack/react-query": "^5.99.2",
26
+ "ai": "6.0.258",
25
27
  "h3": "catalog:",
26
28
  "isbot": "^5",
27
29
  "next-themes": "^0.4.6",
@@ -12,6 +12,8 @@ NPM_CONFIG_PRODUCTION = "false"
12
12
 
13
13
  [context.production.environment]
14
14
  AGENT_NATIVE_HOSTED_HARNESS = "true"
15
+ AGENT_NATIVE_ENABLE_KEEP_WARM = "1"
16
+ AGENT_NATIVE_DISABLE_KEEP_WARM_BACKGROUND = "1"
15
17
  # Macros owns its schema at release time (`migrate:production` above), so
16
18
  # production request functions must not probe the database for migrations.
17
19
  # Preview functions still run request-path migrations because previews do not
@@ -213,10 +213,15 @@ export function RunErrorRecoveryCard({ info, onContinue, onRetry, onFork, onDism
213
213
  const shouldShowMissingProviderSetup = isMissingLlmProviderRunError(info);
214
214
  const isProviderAuthError = isProviderAuthenticationError([info.message, info.details].filter(Boolean).join("\n"), info.errorCode);
215
215
  // Blocked on something the reader goes and fixes elsewhere, then comes back
216
- // to. Without a retry the card is a dead end and its own copy ("then retry")
217
- // points at a button that isn't there.
216
+ // to. Recoverable runs and email verification keep a retry path; rejected
217
+ // provider credentials use the setup flow below so the same bad key is not
218
+ // replayed.
218
219
  const isUnblockableExternally = info.errorCode === "email_verification_required";
219
- const canRetry = canRecover || isProviderAuthError || isUnblockableExternally;
220
+ // Rejected provider keys already have a recovery path: update/connect the
221
+ // credential, then let the setup callback re-run the turn. Exposing a
222
+ // separate retry button here just replays the same rejected credential and
223
+ // turns a permanent auth failure into a loop.
224
+ const canRetry = canRecover || isUnblockableExternally;
220
225
  const builderReconnectResolved = shouldShowBuilderReconnect &&
221
226
  builderReconnect.hasFetchedStatus &&
222
227
  builderReconnect.configured;
@@ -239,6 +239,15 @@ function compareMcpUrl(value) {
239
239
  return value.trim().replace(/\/+$/, "");
240
240
  }
241
241
  }
242
+ function startMcpOAuthReconnect(server) {
243
+ const returnUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
244
+ const params = new URLSearchParams({
245
+ serverId: server.id,
246
+ scope: server.scope,
247
+ return: returnUrl,
248
+ });
249
+ window.location.assign(agentNativePath(`/_agent-native/mcp/servers/oauth/start?${params}`));
250
+ }
242
251
  function McpServerStatus({ server, onReconnect, reconnecting = false, reconnectError, }) {
243
252
  const t = useT();
244
253
  if (server.status.state === "connected") {
@@ -362,7 +371,9 @@ function McpIntegrationsSection({ query }) {
362
371
  return (_jsxs("div", { className: "flex items-start gap-3 py-3.5", children: [_jsx("span", { className: "mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-background text-muted-foreground", children: _jsx(IconServer, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "truncate text-sm font-medium text-foreground", children: server.name }), _jsx("span", { className: "text-[11px] text-muted-foreground", children: server.scope === "user"
363
372
  ? t("mcpIntegrations.personal")
364
373
  : t("mcpIntegrations.sharedWithWorkspace") })] }), _jsx(McpServerStatus, { server: server, onReconnect: server.status.state === "error"
365
- ? () => void reconnect(server)
374
+ ? () => server.authMode === "oauth"
375
+ ? startMcpOAuthReconnect(server)
376
+ : void reconnect(server)
366
377
  : undefined, reconnecting: reconnectingKey === key, reconnectError: reconnectError?.key === key
367
378
  ? reconnectError.message
368
379
  : undefined })] }), canRemove && (_jsx("button", { type: "button", onClick: () => void removeServer(server), disabled: deleteServer.isPending, className: "shrink-0 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50", children: deleteTarget === key ? "Confirm" : "Remove" }))] }, key));
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- ok?: undefined;
30
29
  error: string;
30
+ ok?: undefined;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;
@@ -137,13 +137,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
137
137
  inputSchema: {
138
138
  type: string;
139
139
  properties: {
140
- count?: undefined;
141
140
  query?: undefined;
142
141
  minutes?: undefined;
143
142
  limit?: undefined;
144
143
  clientHint?: undefined;
145
144
  timestamp?: undefined;
146
145
  chapterId?: undefined;
146
+ count?: undefined;
147
147
  startAt?: undefined;
148
148
  endAt?: undefined;
149
149
  reason?: undefined;
@@ -159,7 +159,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
159
159
  inputSchema: {
160
160
  type: string;
161
161
  properties: {
162
- count?: undefined;
163
162
  query: {
164
163
  type: string;
165
164
  description: string;
@@ -175,6 +174,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
175
174
  clientHint?: undefined;
176
175
  timestamp?: undefined;
177
176
  chapterId?: undefined;
177
+ count?: undefined;
178
178
  startAt?: undefined;
179
179
  endAt?: undefined;
180
180
  reason?: undefined;
@@ -190,7 +190,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
190
190
  inputSchema: {
191
191
  type: string;
192
192
  properties: {
193
- count?: undefined;
194
193
  minutes: {
195
194
  type: string;
196
195
  description: string;
@@ -200,6 +199,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
200
199
  clientHint?: undefined;
201
200
  timestamp?: undefined;
202
201
  chapterId?: undefined;
202
+ count?: undefined;
203
203
  startAt?: undefined;
204
204
  endAt?: undefined;
205
205
  reason?: undefined;
@@ -216,7 +216,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
216
216
  type: string;
217
217
  required: string[];
218
218
  properties: {
219
- count?: undefined;
220
219
  query: {
221
220
  type: string;
222
221
  description: string;
@@ -235,6 +234,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
235
234
  };
236
235
  timestamp?: undefined;
237
236
  chapterId?: undefined;
237
+ count?: undefined;
238
238
  startAt?: undefined;
239
239
  endAt?: undefined;
240
240
  reason?: undefined;
@@ -250,7 +250,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
250
250
  type: string;
251
251
  required: string[];
252
252
  properties: {
253
- count?: undefined;
254
253
  query?: undefined;
255
254
  minutes?: undefined;
256
255
  limit?: undefined;
@@ -264,6 +263,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
264
263
  description: string;
265
264
  };
266
265
  chapterId?: undefined;
266
+ count?: undefined;
267
267
  startAt?: undefined;
268
268
  endAt?: undefined;
269
269
  includeMicrophone?: undefined;
@@ -314,13 +314,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
314
314
  type: string;
315
315
  required: string[];
316
316
  properties: {
317
- count?: undefined;
318
317
  query?: undefined;
319
318
  minutes?: undefined;
320
319
  limit?: undefined;
321
320
  clientHint?: undefined;
322
321
  timestamp?: undefined;
323
322
  chapterId?: undefined;
323
+ count?: undefined;
324
324
  startAt: {
325
325
  type: string;
326
326
  description: string;
@@ -349,13 +349,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
349
349
  type: string;
350
350
  required: string[];
351
351
  properties: {
352
- count?: undefined;
353
352
  query?: undefined;
354
353
  minutes?: undefined;
355
354
  limit?: undefined;
356
355
  clientHint?: undefined;
357
356
  timestamp?: undefined;
358
357
  chapterId?: undefined;
358
+ count?: undefined;
359
359
  startAt?: undefined;
360
360
  endAt?: undefined;
361
361
  reason?: undefined;
@@ -16,6 +16,7 @@ export interface McpOAuthFlow {
16
16
  clientInformation: StoredOAuthClientInformation;
17
17
  discoveryState?: McpOAuthDiscoveryState;
18
18
  returnUrl?: string;
19
+ replaceServerId?: string;
19
20
  expiresAt: number;
20
21
  }
21
22
  export interface McpOAuthRoutesOptions {
@@ -8,7 +8,7 @@ import { getH3App } from "../server/framework-request-handler.js";
8
8
  import { getAppUrl, resolveOAuthRedirectUri } from "../server/google-oauth.js";
9
9
  import { runWithRequestContext } from "../server/request-context.js";
10
10
  import { finishMcpOAuthAuthorization, startMcpOAuthAuthorization, validateMcpOAuthCallbackIssuer, } from "./oauth-client.js";
11
- import { addOAuthRemoteServer, normalizeServerName, validateRemoteUrl, } from "./remote-store.js";
11
+ import { addOAuthRemoteServer, listRemoteServers, normalizeServerName, replaceOAuthRemoteServer, validateRemoteUrl, } from "./remote-store.js";
12
12
  const FLOW_COOKIE = "an_mcp_oauth_flow";
13
13
  const FLOW_TTL_SECONDS = 10 * 60;
14
14
  const FLOW_COOKIE_CHUNK_SIZE = 2_800;
@@ -69,8 +69,33 @@ async function handleMcpOAuthStart(event) {
69
69
  if (!session?.email)
70
70
  return unauthorized(event);
71
71
  const query = getQuery(event);
72
- const rawUrl = text(query.url);
73
- const rawName = text(query.name);
72
+ const reconnectServerId = text(query.serverId);
73
+ const reconnectScope = query.scope === "org" ? "org" : "user";
74
+ const reconnectOrg = reconnectScope === "org" ? await getOrgContext(event) : null;
75
+ const reconnectScopeId = reconnectScope === "user" ? session.email : (reconnectOrg?.orgId ?? "");
76
+ let reconnectServer;
77
+ if (reconnectServerId) {
78
+ if (reconnectScope === "org" &&
79
+ (!reconnectScopeId || !isOrgAdmin(reconnectOrg?.role))) {
80
+ setResponseStatus(event, reconnectScopeId ? 403 : 400);
81
+ return {
82
+ error: reconnectScopeId
83
+ ? "Only organization owners and admins can reconnect an org MCP server."
84
+ : "Join an organization before reconnecting an org MCP server.",
85
+ };
86
+ }
87
+ reconnectServer = (await listRemoteServers(reconnectScope, reconnectScopeId)).find((server) => server.id === reconnectServerId);
88
+ if (!reconnectServer) {
89
+ setResponseStatus(event, 404);
90
+ return { error: "MCP server was not found." };
91
+ }
92
+ if (!reconnectServer.oauthSecretKey) {
93
+ setResponseStatus(event, 400);
94
+ return { error: "This MCP server does not use OAuth credentials." };
95
+ }
96
+ }
97
+ const rawUrl = reconnectServer?.url ?? text(query.url);
98
+ const rawName = reconnectServer?.name ?? text(query.name);
74
99
  const returnUrl = text(query.return);
75
100
  if (!rawUrl || !rawName) {
76
101
  setResponseStatus(event, 400);
@@ -159,6 +184,7 @@ async function handleMcpOAuthStart(event) {
159
184
  ? { discoveryState: started.discoveryState }
160
185
  : {}),
161
186
  ...(safeReturnUrl ? { returnUrl: safeReturnUrl } : {}),
187
+ ...(reconnectServerId ? { replaceServerId: reconnectServerId } : {}),
162
188
  expiresAt: Date.now() + FLOW_TTL_SECONDS * 1_000,
163
189
  };
164
190
  setMcpOAuthFlowCookie(event, flow, redirectUri.startsWith("https://"));
@@ -245,12 +271,14 @@ async function handleMcpOAuthCallback(event, options) {
245
271
  authorizationCode: code,
246
272
  iss,
247
273
  }));
248
- const result = await addOAuthRemoteServer(flow.scope, flow.scopeId, {
249
- name: flow.name,
250
- url: flow.url,
251
- description: flow.description,
252
- credentials: finished.credentials,
253
- });
274
+ const result = flow.replaceServerId
275
+ ? await replaceOAuthRemoteServer(flow.scope, flow.scopeId, flow.replaceServerId, finished.credentials)
276
+ : await addOAuthRemoteServer(flow.scope, flow.scopeId, {
277
+ name: flow.name,
278
+ url: flow.url,
279
+ description: flow.description,
280
+ credentials: finished.credentials,
281
+ });
254
282
  if (!result.ok) {
255
283
  setResponseStatus(event, 400);
256
284
  return { error: result.error };
@@ -107,6 +107,18 @@ export declare function addOAuthRemoteServer(scope: RemoteMcpScope, scopeId: str
107
107
  ok: false;
108
108
  error: string;
109
109
  }>;
110
+ /**
111
+ * Replace the OAuth grant for an existing server without changing its id or
112
+ * display name. Reconnect must update the saved grant in place; registering a
113
+ * second row makes the original connection impossible to repair.
114
+ */
115
+ export declare function replaceOAuthRemoteServer(scope: RemoteMcpScope, scopeId: string, serverId: string, credentials: McpOAuthCredentialBundle): Promise<{
116
+ ok: true;
117
+ server: StoredRemoteMcpServer;
118
+ } | {
119
+ ok: false;
120
+ error: string;
121
+ }>;
110
122
  export declare function addFirstPartyRemoteServer(orgId: string, input: {
111
123
  appId: string;
112
124
  name: string;
@@ -193,6 +193,77 @@ export async function addOAuthRemoteServer(scope, scopeId, input) {
193
193
  };
194
194
  }
195
195
  }
196
+ /**
197
+ * Replace the OAuth grant for an existing server without changing its id or
198
+ * display name. Reconnect must update the saved grant in place; registering a
199
+ * second row makes the original connection impossible to repair.
200
+ */
201
+ export async function replaceOAuthRemoteServer(scope, scopeId, serverId, credentials) {
202
+ const credentialResource = validateRemoteUrl(credentials.serverUrl);
203
+ if (!credentialResource.ok || !credentialResource.url) {
204
+ return {
205
+ ok: false,
206
+ error: "MCP server URL must match the OAuth credential resource URL",
207
+ };
208
+ }
209
+ const existing = await readList(scope, scopeId);
210
+ const index = existing.findIndex((server) => server.id === serverId);
211
+ const current = index >= 0 ? existing[index] : undefined;
212
+ if (!current)
213
+ return { ok: false, error: "MCP server was not found" };
214
+ if (!current.oauthSecretKey) {
215
+ return {
216
+ ok: false,
217
+ error: "This MCP server does not use OAuth credentials",
218
+ };
219
+ }
220
+ if (current.url !== credentialResource.url.toString()) {
221
+ return {
222
+ ok: false,
223
+ error: "MCP server URL must match the saved OAuth connection",
224
+ };
225
+ }
226
+ const nextSecretKey = `mcp_oauth:${shortId()}`;
227
+ const nextCredentials = {
228
+ ...credentials,
229
+ serverUrl: credentialResource.url.toString(),
230
+ };
231
+ try {
232
+ await saveMcpOAuthCredentials({
233
+ key: nextSecretKey,
234
+ scope,
235
+ scopeId,
236
+ credentials: nextCredentials,
237
+ });
238
+ const server = {
239
+ ...current,
240
+ url: nextCredentials.serverUrl,
241
+ oauthSecretKey: nextSecretKey,
242
+ };
243
+ const next = [...existing];
244
+ next[index] = server;
245
+ await writeList(scope, scopeId, next);
246
+ await revokeMcpOAuthCredentials({
247
+ key: current.oauthSecretKey,
248
+ scope,
249
+ scopeId,
250
+ serverUrl: current.url,
251
+ }).catch(() => undefined);
252
+ return { ok: true, server };
253
+ }
254
+ catch (err) {
255
+ await revokeMcpOAuthCredentials({
256
+ key: nextSecretKey,
257
+ scope,
258
+ scopeId,
259
+ serverUrl: nextCredentials.serverUrl,
260
+ }).catch(() => undefined);
261
+ return {
262
+ ok: false,
263
+ error: `Failed to replace MCP OAuth credentials: ${err?.message ?? err}`,
264
+ };
265
+ }
266
+ }
196
267
  export async function addFirstPartyRemoteServer(orgId, input) {
197
268
  const trust = await isFirstPartyRemoteEndpointTrusted(orgId, input.appId, input.url);
198
269
  if (!trust.ok)
@@ -16,18 +16,18 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
16
16
  ok?: undefined;
17
17
  error?: undefined;
18
18
  } | {
19
- count?: undefined;
20
19
  updated: number;
21
20
  ok?: undefined;
22
21
  error?: undefined;
23
- } | {
24
22
  count?: undefined;
23
+ } | {
25
24
  updated?: undefined;
26
25
  error: string;
27
26
  ok?: undefined;
28
- } | {
29
27
  count?: undefined;
28
+ } | {
30
29
  updated?: undefined;
31
30
  ok: boolean;
32
31
  error?: undefined;
32
+ count?: undefined;
33
33
  }>>;
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
+ error?: undefined;
44
45
  summary: import("./types.js").TraceSummary;
45
46
  spans: import("./types.js").TraceSpan[];
46
47
  id?: undefined;
47
- error?: undefined;
48
48
  ok?: undefined;
49
49
  } | {
50
+ error?: undefined;
50
51
  summary?: undefined;
51
52
  spans?: undefined;
52
53
  id: string;
53
- error?: undefined;
54
54
  ok?: undefined;
55
55
  } | {
56
56
  summary?: undefined;
@@ -59,9 +59,9 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
59
59
  error: any;
60
60
  ok?: undefined;
61
61
  } | {
62
+ error?: undefined;
62
63
  summary?: undefined;
63
64
  spans?: undefined;
64
65
  id?: undefined;
65
- error?: undefined;
66
66
  ok: boolean;
67
67
  }>>;
@@ -193,6 +193,13 @@ export function createProviderApiDocsSchema(providerSchema = z.string().min(1))
193
193
  search: WebContentSearchSchema.optional().describe("Optional post-fetch search over extracted content by default. Supports query, queries, terms, regex, source, maxMatches, and contextChars."),
194
194
  });
195
195
  }
196
+ const STAGED_DATASET_CAP_ERROR = /Staged dataset (?:byte )?cap exceeded/i;
197
+ function rethrowStagingError(error) {
198
+ if (error instanceof Error && STAGED_DATASET_CAP_ERROR.test(error.message)) {
199
+ throw new Error(`${error.message} Recover by deleting older staged datasets with list-staged-datasets/delete-staged-dataset, or switch this request to saveToFile / a smaller staged result before trying again.`, { cause: error });
200
+ }
201
+ throw error;
202
+ }
196
203
  export function createProviderApiRequestAction(runtime, options = {}) {
197
204
  const schema = options.schema ?? createProviderApiRequestSchema();
198
205
  return defineAction({
@@ -244,10 +251,15 @@ export function createProviderApiRequestAction(runtime, options = {}) {
244
251
  if (!ownerEmail) {
245
252
  throw new Error("No authenticated context for provider API staging.");
246
253
  }
247
- return stagingExecuteRequest(args, runtime.executeRequest, {
248
- appId: options.appId,
249
- ownerEmail,
250
- });
254
+ try {
255
+ return await stagingExecuteRequest(args, runtime.executeRequest, {
256
+ appId: options.appId,
257
+ ownerEmail,
258
+ });
259
+ }
260
+ catch (error) {
261
+ rethrowStagingError(error);
262
+ }
251
263
  }
252
264
  return runtime.executeRequest(args);
253
265
  },
@@ -51,8 +51,8 @@ export declare function handleDeleteResource(event: any): Promise<{
51
51
  error: string;
52
52
  ok?: undefined;
53
53
  } | {
54
- error?: undefined;
55
54
  ok: boolean;
55
+ error?: undefined;
56
56
  }>;
57
57
  /** POST /_agent-native/resources/upload — upload a file as a resource */
58
58
  export declare function handleUploadResource(event: any): Promise<import("./store.js").Resource | {
@@ -73,10 +73,10 @@ export declare function handleUploadResource(event: any): Promise<import("./stor
73
73
  runId: string | null;
74
74
  expiresAt: number | null;
75
75
  metadata: string | null;
76
- error?: undefined;
77
76
  url: string;
78
77
  provider: string;
79
78
  storageSetupRequired?: undefined;
79
+ error?: undefined;
80
80
  } | {
81
81
  error: string;
82
82
  storageSetupRequired: boolean;
@@ -27,10 +27,10 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
27
27
  export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
28
28
  error: any;
29
29
  } | {
30
+ error?: undefined;
30
31
  ok: boolean;
31
32
  key: string;
32
33
  baseUrlKey?: string;
33
34
  scope: AgentEngineApiKeyScope;
34
- error?: undefined;
35
35
  }>>;
36
36
  export {};
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
26
26
  expiresAt?: undefined;
27
27
  ttlSeconds?: undefined;
28
28
  } | {
29
+ error?: undefined;
29
30
  token: string;
30
31
  expiresAt: string;
31
32
  ttlSeconds: number;
32
- error?: undefined;
33
33
  }>>;
@@ -20,6 +20,6 @@ export declare function createTranscribeVoiceHandler(): import("h3").EventHandle
20
20
  error: string;
21
21
  text?: undefined;
22
22
  } | {
23
- text: string;
24
23
  error?: undefined;
24
+ text: string;
25
25
  }>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.0.0-beta-20260820150521",
3
+ "version": "0.0.0-beta-20260820171000",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -428,7 +428,7 @@
428
428
  "yjs": "^13.6.32",
429
429
  "zod": "^4.3.6",
430
430
  "@agent-native/recap-cli": "0.5.5",
431
- "@agent-native/toolkit": "^0.0.0-beta-20260820150521"
431
+ "@agent-native/toolkit": "^0.16.8"
432
432
  },
433
433
  "devDependencies": {
434
434
  "@ai-sdk/anthropic": "^3.0.71",