@agent-native/core 0.70.3 → 0.71.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 (79) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  4. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/agent/durable-background.ts +192 -0
  7. package/corpus/core/src/agent/production-agent.ts +330 -13
  8. package/corpus/core/src/agent/run-manager.ts +66 -3
  9. package/corpus/core/src/agent/run-store.ts +129 -23
  10. package/corpus/core/src/agent/types.ts +20 -0
  11. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  12. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  13. package/corpus/core/src/deploy/build.ts +96 -0
  14. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  15. package/corpus/core/src/mcp/build-server.ts +22 -13
  16. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  17. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  18. package/corpus/templates/analytics/AGENTS.md +8 -0
  19. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  20. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  21. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  22. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  23. package/corpus/templates/clips/chrome-extension/src/background.ts +130 -32
  24. package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
  25. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +195 -34
  26. package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
  27. package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
  28. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  29. package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
  30. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  31. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  32. package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
  33. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  34. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  35. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  36. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  37. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  38. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  39. package/dist/agent/durable-background.d.ts +60 -0
  40. package/dist/agent/durable-background.d.ts.map +1 -0
  41. package/dist/agent/durable-background.js +144 -0
  42. package/dist/agent/durable-background.js.map +1 -0
  43. package/dist/agent/production-agent.d.ts +20 -0
  44. package/dist/agent/production-agent.d.ts.map +1 -1
  45. package/dist/agent/production-agent.js +292 -14
  46. package/dist/agent/production-agent.js.map +1 -1
  47. package/dist/agent/run-manager.d.ts +48 -0
  48. package/dist/agent/run-manager.d.ts.map +1 -1
  49. package/dist/agent/run-manager.js +45 -3
  50. package/dist/agent/run-manager.js.map +1 -1
  51. package/dist/agent/run-store.d.ts +30 -1
  52. package/dist/agent/run-store.d.ts.map +1 -1
  53. package/dist/agent/run-store.js +124 -24
  54. package/dist/agent/run-store.js.map +1 -1
  55. package/dist/agent/types.d.ts +20 -0
  56. package/dist/agent/types.d.ts.map +1 -1
  57. package/dist/agent/types.js.map +1 -1
  58. package/dist/client/AgentPanel.d.ts.map +1 -1
  59. package/dist/client/AgentPanel.js +2 -2
  60. package/dist/client/AgentPanel.js.map +1 -1
  61. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  62. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  63. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  64. package/dist/deploy/build.d.ts +29 -0
  65. package/dist/deploy/build.d.ts.map +1 -1
  66. package/dist/deploy/build.js +85 -0
  67. package/dist/deploy/build.js.map +1 -1
  68. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  69. package/dist/deploy/workspace-deploy.js +108 -0
  70. package/dist/deploy/workspace-deploy.js.map +1 -1
  71. package/dist/mcp/build-server.d.ts.map +1 -1
  72. package/dist/mcp/build-server.js +23 -9
  73. package/dist/mcp/build-server.js.map +1 -1
  74. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  75. package/dist/server/agent-chat-plugin.js +68 -13
  76. package/dist/server/agent-chat-plugin.js.map +1 -1
  77. package/docs/content/multi-app-workspace.md +2 -2
  78. package/docs/design/durable-agent-runs.md +458 -4
  79. package/package.json +1 -1
@@ -34,6 +34,7 @@ import {
34
34
  } from "./workspace-core.js";
35
35
  import { generateActionRegistryForProject } from "../vite/action-types-plugin.js";
36
36
  import { mcpEmbedStaticAssetRouteRules } from "../shared/mcp-embed-headers.js";
37
+ import { AGENT_CHAT_PROCESS_RUN_PATH } from "../agent/durable-background.js";
37
38
  import {
38
39
  AGENT_NATIVE_SOCIAL_IMAGE_ALT,
39
40
  AGENT_NATIVE_SOCIAL_IMAGE_CACHE_BUSTER,
@@ -1476,6 +1477,85 @@ export function findInstalledResvgPackages(
1476
1477
  .map(([packageName, packageDir]) => ({ packageName, packageDir }));
1477
1478
  }
1478
1479
 
1480
+ /**
1481
+ * Deploy-time gate for emitting the second `-background` Netlify function.
1482
+ * Reads the same env flag the runtime gate uses
1483
+ * (`AGENT_CHAT_DURABLE_BACKGROUND`). Off by default — when off, the deploy
1484
+ * emits exactly one function (today's behavior, byte-for-byte).
1485
+ */
1486
+ export function isDurableBackgroundDeployEnabled(): boolean {
1487
+ const raw = process.env.AGENT_CHAT_DURABLE_BACKGROUND;
1488
+ if (raw == null) return false;
1489
+ const v = raw.trim().toLowerCase();
1490
+ return v === "1" || v === "true" || v === "yes" || v === "on";
1491
+ }
1492
+
1493
+ /**
1494
+ * Single-template Netlify build: emit a SECOND function whose name ends in
1495
+ * `-background`, re-exporting the same `main.mjs` handler bundle, so the chat
1496
+ * `_process-run` POST lands on Netlify's async (15-min) function instead of the
1497
+ * synchronous one. Additive + flag-gated (see `isDurableBackgroundDeployEnabled`).
1498
+ *
1499
+ * Nitro's `netlify` preset emits a single function at
1500
+ * `.netlify/functions-internal/server` (`server.mjs` → `main.mjs`). We copy
1501
+ * that directory to a sibling `<...>-background` function and write an entry
1502
+ * with a `config.path` of the process-run route.
1503
+ *
1504
+ * ⚠️ REAL-DEPLOY VERIFICATION REQUIRED. Whether Netlify routes
1505
+ * `/_agent-native/agent-chat/_process-run` to this `-background` function (and
1506
+ * invokes it asynchronously with the 15-min budget) vs the synchronous Nitro
1507
+ * function cannot be verified in this environment — Nitro owns the primary
1508
+ * function's routing manifest, and the precedence between the two functions for
1509
+ * that path is a Netlify runtime behavior. If routing resolves to the
1510
+ * synchronous function, the run still completes via the existing 40s
1511
+ * soft-timeout path (no durable win, no regression). See
1512
+ * docs/design/durable-agent-runs.md (Open risks #1).
1513
+ */
1514
+ export function emitSingleTemplateNetlifyBackgroundFunction(
1515
+ projectCwd: string,
1516
+ ): void {
1517
+ const functionsDir = path.join(projectCwd, ".netlify", "functions-internal");
1518
+ const serverDir = path.join(functionsDir, "server");
1519
+ if (!fs.existsSync(path.join(serverDir, "main.mjs"))) {
1520
+ // Nitro output layout differs from what we expected — skip rather than
1521
+ // guess. The single-function deploy is unaffected.
1522
+ console.warn(
1523
+ "[build] Durable-background emit skipped: expected Nitro Netlify function " +
1524
+ "at .netlify/functions-internal/server/main.mjs was not found.",
1525
+ );
1526
+ return;
1527
+ }
1528
+ const backgroundName = "server-agent-background";
1529
+ const dest = path.join(functionsDir, backgroundName);
1530
+ fs.rmSync(dest, { recursive: true, force: true });
1531
+ copyDir(serverDir, dest);
1532
+ // Drop the original Nitro entry so our background entry is the entrypoint.
1533
+ fs.rmSync(path.join(dest, "server.mjs"), { force: true });
1534
+
1535
+ const entry = `let cachedHandler;
1536
+
1537
+ export default async function handler(...args) {
1538
+ cachedHandler ??= (await import("./main.mjs")).default;
1539
+ return cachedHandler(...args);
1540
+ }
1541
+
1542
+ export const config = {
1543
+ name: "agent background handler",
1544
+ generator: "agent-native build",
1545
+ path: ${JSON.stringify([AGENT_CHAT_PROCESS_RUN_PATH])},
1546
+ nodeBundler: "none",
1547
+ includedFiles: ["**"],
1548
+ preferStatic: false,
1549
+ };
1550
+ `;
1551
+ fs.writeFileSync(path.join(dest, `${backgroundName}.mjs`), entry);
1552
+ console.log(
1553
+ `[build] Emitted durable-background function "${backgroundName}" ` +
1554
+ `(path ${AGENT_CHAT_PROCESS_RUN_PATH}). REQUIRES real-deploy verification ` +
1555
+ `of Netlify async routing — see docs/design/durable-agent-runs.md.`,
1556
+ );
1557
+ }
1558
+
1479
1559
  function copyInstalledLibsqlNativePackages(serverDir: string | undefined) {
1480
1560
  if (!serverDir || !fs.existsSync(serverDir)) return;
1481
1561
  const nodeModulesRoots = nodeModulesAncestors(cwd);
@@ -1938,6 +2018,22 @@ export default bundle;
1938
2018
  copyInstalledFfmpegStaticPackage(nitro.options.output.serverDir);
1939
2019
  }
1940
2020
 
2021
+ // Durable background agent runs (off by default). Additive ONLY: emits a
2022
+ // SECOND Netlify function whose name ends in `-background` re-exporting the
2023
+ // same handler bundle, so the chat `_process-run` POST lands on Netlify's
2024
+ // async (15-min) function. When the flag is off this is a no-op and the
2025
+ // single-function deploy is byte-for-byte unchanged.
2026
+ if (preset === "netlify" && isDurableBackgroundDeployEnabled()) {
2027
+ try {
2028
+ emitSingleTemplateNetlifyBackgroundFunction(cwd);
2029
+ } catch (err) {
2030
+ console.warn(
2031
+ "[build] Failed to emit durable-background Netlify function (non-fatal):",
2032
+ err instanceof Error ? err.message : err,
2033
+ );
2034
+ }
2035
+ }
2036
+
1941
2037
  // Resolve remaining bare npm imports by bundling them into _libs/.
1942
2038
  // Nitro sometimes leaves small packages as externals even with noExternals.
1943
2039
  if (preset.startsWith("cloudflare") || preset.startsWith("deno")) {
@@ -32,6 +32,7 @@ import {
32
32
  collectImmutableAssetPaths,
33
33
  IMMUTABLE_ASSET_CACHE_HEADERS,
34
34
  } from "./immutable-assets.js";
35
+ import { AGENT_CHAT_PROCESS_RUN_PATH } from "../agent/durable-background.js";
35
36
 
36
37
  export type WorkspaceDeployPreset = "cloudflare_pages" | "netlify" | "vercel";
37
38
 
@@ -665,6 +666,126 @@ function copyNetlifyFunctionIntoWorkspace(
665
666
  fs.rmSync(dest, { recursive: true, force: true });
666
667
  copyDir(src, dest);
667
668
  patchNetlifyFunctionEntry(dest, app, workspaceApps, staticDir);
669
+
670
+ // Durable background agent runs (off by default). Additive ONLY: when the
671
+ // flag is off this emits nothing and the single-function deploy is unchanged.
672
+ if (isDurableBackgroundDeployEnabled()) {
673
+ emitNetlifyBackgroundFunction(workspaceRoot, app, src, workspaceApps);
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Deploy-time gate for emitting the second `-background` Netlify function. Reads
679
+ * the same env flag the runtime gate uses (`AGENT_CHAT_DURABLE_BACKGROUND`).
680
+ * Off by default — when off, the deploy emits exactly one function per app
681
+ * (today's behavior, byte-for-byte).
682
+ */
683
+ function isDurableBackgroundDeployEnabled(): boolean {
684
+ const raw = process.env.AGENT_CHAT_DURABLE_BACKGROUND;
685
+ if (raw == null) return false;
686
+ const v = raw.trim().toLowerCase();
687
+ return v === "1" || v === "true" || v === "yes" || v === "on";
688
+ }
689
+
690
+ /**
691
+ * Emit a SECOND Netlify function for `app` whose name ends in `-background`,
692
+ * re-exporting the SAME `main.mjs` handler bundle. Netlify invokes any function
693
+ * whose deployed name ends in `-background` asynchronously (202 immediately, up
694
+ * to 15-min budget), which is exactly what the durable-background chat
695
+ * dispatch (`fireInternalDispatch` → AGENT_CHAT_PROCESS_RUN_PATH) needs.
696
+ *
697
+ * The function is given a `config.path` of the chat process-run route so
698
+ * Netlify routes that POST to this async function instead of the synchronous
699
+ * `<app>-server` function. It shares the same bundle (`includedFiles: ["**"]`)
700
+ * so `A2A_SECRET`, the DB URL, and the rest of the env/bundle are present.
701
+ *
702
+ * ⚠️ REAL-DEPLOY VERIFICATION REQUIRED. The exact Netlify routing/precedence
703
+ * between this `-background` function's `config.path` and the synchronous
704
+ * `<app>-server` function's broader `config.path` (which also matches
705
+ * `/_agent-native/*` for the dispatch app, or `/<app>/*` otherwise) cannot be
706
+ * verified in this environment. It is emitted additively and only under the
707
+ * flag; if Netlify resolves the overlap to the wrong function, the dispatch
708
+ * would land on the synchronous function (no 15-min budget) — the run would
709
+ * still work via the existing 40s soft-timeout path, just without the durable
710
+ * win. See packages/core/docs/design/durable-agent-runs.md.
711
+ */
712
+ function emitNetlifyBackgroundFunction(
713
+ workspaceRoot: string,
714
+ app: string,
715
+ srcServerDir: string,
716
+ workspaceApps: WorkspaceAppManifestEntry[],
717
+ ): void {
718
+ // Name MUST end in `-background` for Netlify async invocation.
719
+ const backgroundName = `${app}-agent-background`;
720
+ const dest = path.join(netlifyFunctionsDir(workspaceRoot), backgroundName);
721
+ fs.rmSync(dest, { recursive: true, force: true });
722
+ copyDir(srcServerDir, dest);
723
+
724
+ const basePath = `/${app}`;
725
+ const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
726
+ const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
727
+ workspaceApps,
728
+ app,
729
+ );
730
+ // Only the chat process-run route needs the async function. Keeping the path
731
+ // narrow avoids stealing any other traffic from the synchronous function.
732
+ // The dispatch URL is app-base-path-prefixed (same as the agent-teams
733
+ // processor dispatch), so the route Netlify must map to this async function
734
+ // is `/<app>/_agent-native/agent-chat/_process-run`.
735
+ const processRunPath = `${basePath}${AGENT_CHAT_PROCESS_RUN_PATH}`;
736
+ const pathConfig = [processRunPath];
737
+ const server = `const basePath = ${JSON.stringify(basePath)};
738
+
739
+ function setBasePathEnv() {
740
+ const processRef = globalThis.process ??= { env: {} };
741
+ processRef.env ??= {};
742
+ Object.assign(processRef.env, {
743
+ AGENT_NATIVE_WORKSPACE: "1",
744
+ AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
745
+ APP_BASE_PATH: basePath,
746
+ AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
747
+ AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
748
+ AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
749
+ VITE_AGENT_NATIVE_WORKSPACE: "1",
750
+ VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
751
+ VITE_APP_BASE_PATH: basePath,
752
+ VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
753
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
754
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
755
+ VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: ${JSON.stringify(JSON.stringify(workspaceApps))},
756
+ ${JSON.stringify(WORKSPACE_APPS_ENV_KEY)}: ${JSON.stringify(JSON.stringify(workspaceApps))},
757
+ });
758
+ }
759
+
760
+ setBasePathEnv();
761
+
762
+ let cachedHandler;
763
+
764
+ export default async function handler(...args) {
765
+ setBasePathEnv();
766
+ cachedHandler ??= (await import("./main.mjs")).default;
767
+ return cachedHandler(...args);
768
+ }
769
+
770
+ export const config = {
771
+ name: ${JSON.stringify(`${app} agent background handler`)},
772
+ generator: "agent-native workspace deploy",
773
+ path: ${JSON.stringify(pathConfig)},
774
+ nodeBundler: "none",
775
+ includedFiles: ["**"],
776
+ preferStatic: false,
777
+ };
778
+ `;
779
+ // Remove the original Nitro entry (server.mjs) so only our background entry
780
+ // is the function entrypoint, mirroring patchNetlifyFunctionEntry.
781
+ fs.rmSync(path.join(dest, "server.mjs"), { force: true });
782
+ fs.writeFileSync(path.join(dest, `${backgroundName}.mjs`), server);
783
+ console.log(
784
+ `[workspace-deploy] Emitted durable-background function "${backgroundName}" ` +
785
+ `for app "${app}" (path ${processRunPath}). ` +
786
+ `REQUIRES real-deploy verification of Netlify async routing — see ` +
787
+ `docs/design/durable-agent-runs.md.`,
788
+ );
668
789
  }
669
790
 
670
791
  function patchNetlifyFunctionEntry(
@@ -952,12 +952,13 @@ async function resolveMcpAppResource(
952
952
  ): Promise<ResolvedMcpAppResource | null> {
953
953
  const resource = entry.mcpApp?.resource;
954
954
  if (!resource) return null;
955
- // Inline MCP App embeds are gated behind the deploy-toggleable kill switch
956
- // (default off). When disabled, advertise no resource and attach no embed
957
- // reference so hosts fall back to the tool's deep-link text instead of an
958
- // iframe. This is the single chokepoint for every embed surface: tools/list
959
- // descriptor meta, tools/call result meta, resources/list, resources/read.
960
- if (!requestMeta?.inlineMcpApps) return null;
955
+ // NB: the inline kill switch is intentionally NOT enforced here. This
956
+ // resolver also backs `resources/read`, which must keep serving the shell
957
+ // for a URI the host already holds (e.g. a cached descriptor) so it degrades
958
+ // gracefully instead of throwing a hard `-32603`. The switch is enforced at
959
+ // the *advertisement/render* sites (`tools/list` descriptor meta,
960
+ // `tools/call` result meta, `resources/list`) so disabled embeds never get
961
+ // advertised in the first place.
961
962
  const resolvedUri = getMcpAppResourceUri(config, actionName, entry);
962
963
  if (!resolvedUri) return null;
963
964
  const description = resource.description ?? entry.tool.description;
@@ -1006,6 +1007,9 @@ async function getMcpAppResources(
1006
1007
  actions: Record<string, ActionEntry>,
1007
1008
  requestMeta?: MCPRequestMeta,
1008
1009
  ): Promise<ResolvedMcpAppResource[]> {
1010
+ // Advertisement path (resources/list + resources/templates/list): suppressed
1011
+ // by the inline kill switch so disabled embeds are never listed.
1012
+ if (!requestMeta?.inlineMcpApps) return [];
1009
1013
  const resources = await Promise.all(
1010
1014
  Object.entries(actions).map(([name, entry]) =>
1011
1015
  resolveMcpAppResourceSafely(config, name, entry, requestMeta),
@@ -1383,7 +1387,10 @@ export async function createMCPServerForRequest(
1383
1387
  : {};
1384
1388
  const toolMeta = {
1385
1389
  ...rawToolMeta,
1386
- ...(mcpAppResource
1390
+ // Advertisement path: only tag the tool with its inline-embed
1391
+ // descriptor when the kill switch is on, so disabled embeds never
1392
+ // prompt a host to render/read the `ui://` resource.
1393
+ ...(mcpAppResource && requestMeta?.inlineMcpApps
1387
1394
  ? {
1388
1395
  ...openAiToolDescriptorMeta(mcpAppResource),
1389
1396
  [MCP_APP_RESOURCE_URI_META_KEY]: mcpAppResource.uri,
@@ -1533,12 +1540,14 @@ export async function createMCPServerForRequest(
1533
1540
  !!mcpResult.raw &&
1534
1541
  typeof mcpResult.raw === "object" &&
1535
1542
  (mcpResult.raw as Record<string, unknown>).isError === true;
1536
- const mcpAppResource = await resolveMcpAppResourceSafely(
1537
- config,
1538
- name,
1539
- entry,
1540
- requestMeta,
1541
- );
1543
+ // Render path: only treat the result as an inline embed when the kill
1544
+ // switch is on. When off, `mcpAppResource` is null so every embed
1545
+ // branch below degrades to the plain deep-link artifacts the tool would
1546
+ // otherwise return — no `openai/outputTemplate`, no minted embed-start,
1547
+ // no embed structuredContent — so the host shows a link, not an iframe.
1548
+ const mcpAppResource = requestMeta?.inlineMcpApps
1549
+ ? await resolveMcpAppResourceSafely(config, name, entry, requestMeta)
1550
+ : null;
1542
1551
  const rawResultForClient = mcpAppResource
1543
1552
  ? await withServerMintedMcpAppEmbedStart(rawResult, requestMeta)
1544
1553
  : rawResult;
@@ -150,6 +150,10 @@ import {
150
150
  hasConfiguredA2ASecret,
151
151
  isA2AProductionRuntime,
152
152
  } from "../a2a/auth-policy.js";
153
+ import {
154
+ AGENT_CHAT_PROCESS_RUN_PATH,
155
+ prepareProcessRunRequest,
156
+ } from "../agent/durable-background.js";
153
157
  import {
154
158
  getBuilderBrowserConnectUrlForOwner,
155
159
  resolveBuilderBranchProjectId,
@@ -7695,6 +7699,134 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
7695
7699
  }),
7696
7700
  );
7697
7701
 
7702
+ // Shared per-request invocation: resolve auth/org/timezone context, then
7703
+ // pick the dev/prod/anonymous handler and run it inside the request
7704
+ // context. Used by the main chat POST and by the durable-background
7705
+ // `_process-run` processor route (which re-enters the same handler set as
7706
+ // the background worker), so both go through identical context + handler
7707
+ // selection.
7708
+ const invokeAgentChatHandler = async (event: any) => {
7709
+ // Resolve per-request auth context
7710
+ const ownerContext = await resolveOwnerContext(event);
7711
+ const owner = ownerContext.owner;
7712
+
7713
+ // Resolve org ID: explicit callback > session.orgId from Better Auth
7714
+ // > implicit org membership. Better Auth leaves session.orgId null
7715
+ // until the user explicitly switches orgs, so a fresh signup with
7716
+ // implicit membership (e.g. domain-matched org) would otherwise see
7717
+ // no org-scoped credentials. getOrgContext() does the same DB lookup
7718
+ // the /builder/status endpoint uses to decide "Connected".
7719
+ let resolvedOrgId: string | undefined;
7720
+ if (options?.resolveOrgId) {
7721
+ resolvedOrgId = (await options.resolveOrgId(event)) ?? undefined;
7722
+ } else {
7723
+ try {
7724
+ const session = await getSession(event);
7725
+ resolvedOrgId = session?.orgId ?? undefined;
7726
+ } catch {
7727
+ // Session not available
7728
+ }
7729
+ if (!resolvedOrgId) {
7730
+ try {
7731
+ const { getOrgContext } = await import("../org/context.js");
7732
+ const ctx = await getOrgContext(event);
7733
+ resolvedOrgId = ctx.orgId ?? undefined;
7734
+ } catch {
7735
+ // org_members table may not exist yet on first boot
7736
+ }
7737
+ }
7738
+ }
7739
+
7740
+ // Propagate the caller's IANA timezone from `x-user-timezone` so that
7741
+ // tool calls made by the agent (e.g. log-meal with no explicit date)
7742
+ // resolve "today" in the user's local timezone instead of server UTC.
7743
+ const tzRaw = getHeader(event, "x-user-timezone");
7744
+ const timezone =
7745
+ typeof tzRaw === "string" &&
7746
+ tzRaw.trim().length > 0 &&
7747
+ tzRaw.trim().length < 64
7748
+ ? tzRaw.trim()
7749
+ : undefined;
7750
+
7751
+ return runWithRequestContext(
7752
+ {
7753
+ userEmail: owner,
7754
+ userName: ownerContext.name,
7755
+ orgId: resolvedOrgId,
7756
+ timezone,
7757
+ },
7758
+ () => {
7759
+ // App-rendered chat can't host direct code edits — HMR/full
7760
+ // reloads would kill the same chat surface mid-run. Force the
7761
+ // prod handler (no shell / no fs); the prompt block injected by
7762
+ // `prodHandler.systemPrompt` then steers source changes to a
7763
+ // separate agent surface such as Builder or the dev frame.
7764
+ const blockInProductCodeEditing =
7765
+ shouldBlockInProductCodeEditing(event);
7766
+ const handler =
7767
+ ownerContext.anonymous && anonymousHandler
7768
+ ? anonymousHandler
7769
+ : !blockInProductCodeEditing && currentDevMode && devHandler
7770
+ ? devHandler
7771
+ : prodHandler;
7772
+ return handler(event);
7773
+ },
7774
+ );
7775
+ };
7776
+
7777
+ // ─── Durable background agent-chat run processor ──────────────────────
7778
+ // Self-fire target for a long chat turn. The foreground POST claims the
7779
+ // run slot, inserts the run row, and `fireInternalDispatch`es here; this
7780
+ // route runs INSIDE the Netlify background function (15-min budget). It
7781
+ // HMAC-verifies the dispatch (same internal-token scheme as the agent-
7782
+ // teams / A2A / webhook processors), injects the background-run marker,
7783
+ // and re-enters the SAME agent-chat handler as the background worker,
7784
+ // which runs the full multi-step turn inline with the ~13min soft
7785
+ // timeout. With AGENT_CHAT_DURABLE_BACKGROUND off, the foreground never
7786
+ // dispatches here, so this route is never exercised.
7787
+ getH3App(nitroApp).use(
7788
+ AGENT_CHAT_PROCESS_RUN_PATH,
7789
+ defineEventHandler(async (event) => {
7790
+ if (getMethod(event) !== "POST") {
7791
+ setResponseStatus(event, 405);
7792
+ return { error: "Method not allowed" };
7793
+ }
7794
+ // Consume the body ONCE (h3 v2's web Request stream is single-use).
7795
+ let processBody: any;
7796
+ try {
7797
+ processBody = await readBody(event);
7798
+ } catch {
7799
+ setResponseStatus(event, 400);
7800
+ return { error: "Invalid request body" };
7801
+ }
7802
+
7803
+ // Validate + HMAC-authenticate the self-dispatch and prepare the
7804
+ // background-worker body. Pure decision (unit-tested in
7805
+ // durable-background.spec.ts); the route only wires it to h3.
7806
+ const prepared = prepareProcessRunRequest(
7807
+ processBody,
7808
+ getHeader(event, "authorization"),
7809
+ );
7810
+ if (!prepared.ok) {
7811
+ setResponseStatus(event, prepared.status);
7812
+ return { error: prepared.error };
7813
+ }
7814
+
7815
+ // Stash the verified+augmented body for the handler — the body stream
7816
+ // is already consumed, so the handler reads this instead.
7817
+ (event as any).context = (event as any).context ?? {};
7818
+ (event as any).context.__agentChatBackgroundBody = prepared.body;
7819
+
7820
+ try {
7821
+ return await invokeAgentChatHandler(event);
7822
+ } catch (err: any) {
7823
+ console.error("[agent-chat] _process-run failed:", err);
7824
+ setResponseStatus(event, 500);
7825
+ return { error: "process-run failed" };
7826
+ }
7827
+ }),
7828
+ );
7829
+
7698
7830
  // Mount the main chat handler — delegates to dev or prod handler based on current mode.
7699
7831
  // This is mounted last because h3's use() is prefix-based, meaning /_agent-native/agent-chat
7700
7832
  // also matches /_agent-native/agent-chat/threads/... — we skip sub-path requests here so the
@@ -7713,72 +7845,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
7713
7845
  return { error: "Not found" };
7714
7846
  }
7715
7847
 
7716
- // Resolve per-request auth context
7717
- const ownerContext = await resolveOwnerContext(event);
7718
- const owner = ownerContext.owner;
7719
-
7720
- // Resolve org ID: explicit callback > session.orgId from Better Auth
7721
- // > implicit org membership. Better Auth leaves session.orgId null
7722
- // until the user explicitly switches orgs, so a fresh signup with
7723
- // implicit membership (e.g. domain-matched org) would otherwise see
7724
- // no org-scoped credentials. getOrgContext() does the same DB lookup
7725
- // the /builder/status endpoint uses to decide "Connected".
7726
- let resolvedOrgId: string | undefined;
7727
- if (options?.resolveOrgId) {
7728
- resolvedOrgId = (await options.resolveOrgId(event)) ?? undefined;
7729
- } else {
7730
- try {
7731
- const session = await getSession(event);
7732
- resolvedOrgId = session?.orgId ?? undefined;
7733
- } catch {
7734
- // Session not available
7735
- }
7736
- if (!resolvedOrgId) {
7737
- try {
7738
- const { getOrgContext } = await import("../org/context.js");
7739
- const ctx = await getOrgContext(event);
7740
- resolvedOrgId = ctx.orgId ?? undefined;
7741
- } catch {
7742
- // org_members table may not exist yet on first boot
7743
- }
7744
- }
7745
- }
7746
-
7747
- // Propagate the caller's IANA timezone from `x-user-timezone` so that
7748
- // tool calls made by the agent (e.g. log-meal with no explicit date)
7749
- // resolve "today" in the user's local timezone instead of server UTC.
7750
- const tzRaw = getHeader(event, "x-user-timezone");
7751
- const timezone =
7752
- typeof tzRaw === "string" &&
7753
- tzRaw.trim().length > 0 &&
7754
- tzRaw.trim().length < 64
7755
- ? tzRaw.trim()
7756
- : undefined;
7757
-
7758
- return runWithRequestContext(
7759
- {
7760
- userEmail: owner,
7761
- userName: ownerContext.name,
7762
- orgId: resolvedOrgId,
7763
- timezone,
7764
- },
7765
- () => {
7766
- // App-rendered chat can't host direct code edits — HMR/full
7767
- // reloads would kill the same chat surface mid-run. Force the
7768
- // prod handler (no shell / no fs); the prompt block injected by
7769
- // `prodHandler.systemPrompt` then steers source changes to a
7770
- // separate agent surface such as Builder or the dev frame.
7771
- const blockInProductCodeEditing =
7772
- shouldBlockInProductCodeEditing(event);
7773
- const handler =
7774
- ownerContext.anonymous && anonymousHandler
7775
- ? anonymousHandler
7776
- : !blockInProductCodeEditing && currentDevMode && devHandler
7777
- ? devHandler
7778
- : prodHandler;
7779
- return handler(event);
7780
- },
7781
- );
7848
+ return invokeAgentChatHandler(event);
7782
7849
  }),
7783
7850
  );
7784
7851
 
@@ -174,12 +174,31 @@ pnpm action update-dashboard --dashboardId weekly-metrics --config '<full json>'
174
174
 
175
175
  After a mutation, navigate to the dashboard if the user is elsewhere. The app syncs through the framework's polling/query invalidation path.
176
176
 
177
+ ## Building Large First-Party Dashboards (compose-dashboard)
178
+
179
+ For a **first-party analytics** dashboard, prefer `compose-dashboard` over hand-authoring a big `update-dashboard` config. You name the metrics; the SERVER expands each into a full, validated panel (SQL + chart config) from the shipped metric catalog and saves them in ONE atomic call. This avoids the failure mode where the agent must stream a giant multi-panel `update-dashboard` argument inside the ~40s budget — that big tool-call can't be resumed mid-stream and is all-or-nothing on validation, so the agent thrashes (repeated update-dashboard + tool-search, never landing).
180
+
181
+ - **Never hand-author large first-party configs panel-by-panel.** Call `compose-dashboard` with the metric keys instead.
182
+ - Unknown metric keys are skipped and reported in `unknownMetrics` (not fatal). Each panel's SQL is validated independently — valid panels save, invalid ones are reported in `invalidMetrics`.
183
+ - By default (no `overwrite`), composing into an existing dashboard APPENDS the new panels and skips ids already present. `overwrite: true` replaces the whole config.
184
+ - Each metric accepts an optional per-metric `window` of `'30d' | '90d' | 'all'` (only affects windowed virality/time metrics) and `title` / `chartType` / `width` overrides.
185
+ - Returns `{ dashboardId, panelCount, createdMetrics, unknownMetrics, invalidMetrics, skippedExistingIds }` — report `panelCount` as proof-of-done.
186
+
187
+ Available metric keys: `total-signups`, `signups-over-time`, `signups-by-template`, `sessions-by-app`, `sessions-over-time`, `signed-in-vs-anon`, `total-template-clicks`, `total-demo-clicks`, `total-cli-copies`, `template-interest-over-time`, `clicks-by-template`, `demo-clicks-by-template`, `cli-copies-by-template`, `cli-copies-over-time`, `pageviews-over-time`, `referred-signups-30d`, `viral-signup-share-30d`, `clip-share-signups-30d`, `signups-by-referral-source`, `referred-signups-over-time`, `top-referrers`, `share-funnel-30d`, `viral-participation-rate-90d`, `viral-coefficient-90d`, `activated-referrers-90d`.
188
+
189
+ ```bash
190
+ # Build a large first-party dashboard in ONE call (server generates the panels)
191
+ pnpm action compose-dashboard --dashboardId first-party-overview --title "First-Party Overview" \
192
+ --metrics '["total-signups","signups-over-time","signups-by-template","sessions-by-app","viral-coefficient-90d","top-referrers","share-funnel-30d"]'
193
+ ```
194
+
177
195
  ## Reliable Bulk Edits
178
196
 
179
197
  This is the dashboard-specific application of the framework-wide `reliable-mutations` skill — read that for the general rule (one atomic write, verify end state, report proof-of-done).
180
198
 
181
199
  Hosted agent runs have a **~40s budget**. Many sequential `update-dashboard` calls (one per panel, plus schema-discovery calls) will blow that budget and leave the dashboard in a partial state — earlier inserts looked like they succeeded (✓), but nothing actually persisted. Avoid this:
182
200
 
201
+ - **For a large first-party dashboard, use `compose-dashboard`** (see the section above): name the metrics, the server generates the panels in one call. Do not hand-author the big config.
183
202
  - **Batch ALL changes into ONE `update-dashboard` call.** A single `update-dashboard` is atomic: it applies every op to an in-memory config, validates all panel SQL, then upserts once. Never loop the action.
184
203
  - To add N panels, pass N ops in one call: `ops: [{op:"insert", path:"/panels/-", value:<panel>}, … ]` (`/panels/-` appends to the end).
185
204
  - The `ops` format needs no discovery: each op is `{ op, path, from?, value? }`, `op ∈ set | replace | remove | insert | move | move-before`, and `path` is a JSON Pointer (e.g. `/panels/3`, `/panels/3/title`, `/name`).
@@ -83,6 +83,14 @@ details live in `.agents/skills/`.
83
83
 
84
84
  ## Dashboard Template Catalog
85
85
 
86
+ - To build or extend a LARGE first-party dashboard, prefer `compose-dashboard`:
87
+ name the metrics and the server generates the validated SQL/config for every
88
+ panel in ONE fast call. Do NOT hand-author big `update-dashboard` configs
89
+ panel-by-panel or loop `update-dashboard` — streaming a giant multi-panel
90
+ argument inside the ~40s budget fails and thrashes. Unknown metric keys are
91
+ skipped and reported; per-panel SQL validates independently; existing
92
+ dashboards append by default (`overwrite: true` replaces). Report the returned
93
+ `panelCount` as proof-of-done.
86
94
  - `list-dashboard-templates` lists source-controlled dashboard templates with
87
95
  `id`, category, data sources, panel count, and installed dashboard IDs.
88
96
  - `install-dashboard-template` installs a catalog template into normal