@agent-native/core 0.75.0 → 0.75.2

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 (48) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +46 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +71 -3
  5. package/corpus/core/src/agent/production-agent.ts +16 -3
  6. package/corpus/core/src/cli/skills.ts +15 -11
  7. package/corpus/core/src/deploy/build.ts +100 -30
  8. package/corpus/core/src/templates/default/_gitignore +1 -0
  9. package/corpus/core/src/templates/headless/_gitignore +1 -0
  10. package/corpus/core/src/templates/workspace-root/_gitignore +1 -0
  11. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +0 -8
  12. package/corpus/templates/analytics/actions/update-dashboard.ts +3 -1
  13. package/corpus/templates/analytics/app/components/SqlEditor.tsx +57 -0
  14. package/corpus/templates/analytics/app/components/SqlHighlight.tsx +27 -28
  15. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +130 -8
  16. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -3
  17. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/ViewSqlPopover.tsx +4 -4
  18. package/corpus/templates/analytics/changelog/2026-06-24-active-user-dashboard-panels-classify-templates-more-accurat.md +6 -0
  19. package/corpus/templates/analytics/changelog/2026-06-24-dashboard-panel-deletes-now-stay-deleted-after-refresh.md +6 -0
  20. package/corpus/templates/analytics/changelog/2026-06-24-sql-panel-editors-now-highlight-query-syntax-and-keep-one-cl.md +6 -0
  21. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +2 -2
  22. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +9 -11
  23. package/corpus/templates/clips/actions/list-recordings.ts +21 -0
  24. package/corpus/templates/clips/app/components/library/library-layout.tsx +21 -2
  25. package/corpus/templates/clips/app/hooks/use-library.ts +18 -0
  26. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +7 -5
  27. package/dist/agent/durable-background.d.ts +51 -3
  28. package/dist/agent/durable-background.d.ts.map +1 -1
  29. package/dist/agent/durable-background.js +64 -3
  30. package/dist/agent/durable-background.js.map +1 -1
  31. package/dist/agent/production-agent.d.ts.map +1 -1
  32. package/dist/agent/production-agent.js +16 -3
  33. package/dist/agent/production-agent.js.map +1 -1
  34. package/dist/cli/skills.d.ts +1 -1
  35. package/dist/cli/skills.d.ts.map +1 -1
  36. package/dist/cli/skills.js +15 -11
  37. package/dist/cli/skills.js.map +1 -1
  38. package/dist/deploy/build.d.ts +38 -17
  39. package/dist/deploy/build.d.ts.map +1 -1
  40. package/dist/deploy/build.js +96 -29
  41. package/dist/deploy/build.js.map +1 -1
  42. package/dist/templates/default/_gitignore +1 -0
  43. package/dist/templates/headless/_gitignore +1 -0
  44. package/dist/templates/workspace-root/_gitignore +1 -0
  45. package/package.json +1 -1
  46. package/src/templates/default/_gitignore +1 -0
  47. package/src/templates/headless/_gitignore +1 -0
  48. package/src/templates/workspace-root/_gitignore +1 -0
@@ -3,11 +3,14 @@ import {
3
3
  useCallback,
4
4
  useContext,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useMemo,
8
+ useRef,
7
9
  useState,
8
10
  type CSSProperties,
9
11
  type ReactNode,
10
12
  } from "react";
13
+ import { createPortal } from "react-dom";
11
14
  import {
12
15
  Area,
13
16
  AreaChart,
@@ -49,7 +52,6 @@ import {
49
52
 
50
53
  const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
51
54
  import { useSqlQuery } from "@/lib/sql-query";
52
- import { useChartTooltipFlip } from "@/hooks/use-chart-tooltip-flip";
53
55
  import type {
54
56
  SqlPanel,
55
57
  ChartType,
@@ -75,6 +77,11 @@ const CHART_TOOLTIP_WRAPPER_STYLE: CSSProperties = {
75
77
  pointerEvents: "none",
76
78
  };
77
79
 
80
+ const PORTAL_GUTTER_PADDING = 12;
81
+ const PORTAL_CURSOR_OFFSET = 24;
82
+ const useBrowserLayoutEffect =
83
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
84
+
78
85
  const CHART_TOOLTIP_PROPS = {
79
86
  allowEscapeViewBox: { x: true, y: true },
80
87
  wrapperStyle: CHART_TOOLTIP_WRAPPER_STYLE,
@@ -435,11 +442,15 @@ function ChartTooltip({
435
442
  seriesNameFormatter?: (value: string) => string;
436
443
  valueFormatter?: (value: number) => string;
437
444
  }) {
438
- const tooltipRef = useChartTooltipFlip<HTMLDivElement>();
445
+ const anchorRef = useRef<HTMLDivElement | null>(null);
446
+ const portalRef = useRef<HTMLDivElement | null>(null);
447
+ const [portalPosition, setPortalPosition] = useState<{
448
+ left: number;
449
+ top: number;
450
+ } | null>(null);
439
451
  const items = sortTooltipPayloadItems(
440
452
  payload?.filter((item) => item.value != null && item.value !== "") ?? [],
441
453
  );
442
- if (!active || items.length === 0) return null;
443
454
 
444
455
  const labelText =
445
456
  label == null
@@ -448,11 +459,96 @@ function ChartTooltip({
448
459
  ? labelFormatter(String(label))
449
460
  : String(label);
450
461
 
451
- return (
452
- <div
453
- ref={tooltipRef}
454
- className="min-w-40 max-w-[280px] rounded-md border border-border bg-card px-3 py-2 text-xs text-foreground shadow-lg"
455
- >
462
+ useBrowserLayoutEffect(() => {
463
+ if (!active || items.length === 0 || typeof window === "undefined") {
464
+ setPortalPosition(null);
465
+ return;
466
+ }
467
+
468
+ const anchor = anchorRef.current;
469
+ const wrapper = anchor?.parentElement;
470
+ if (!anchor || !wrapper) return;
471
+
472
+ let frame = 0;
473
+ const apply = () => {
474
+ frame = 0;
475
+ if (!(wrapper as HTMLElement).style.transform) return;
476
+
477
+ const anchorRect = anchor.getBoundingClientRect();
478
+ const portalRect = portalRef.current?.getBoundingClientRect();
479
+ const width = portalRect?.width || anchorRect.width;
480
+ const height = portalRect?.height || anchorRect.height;
481
+ if (width === 0 || height === 0) return;
482
+
483
+ const sidebar = document.querySelector(".agent-sidebar-panel");
484
+ const sidebarRect = sidebar?.getBoundingClientRect();
485
+ const rightLimit =
486
+ sidebarRect && sidebarRect.width > 0 && sidebarRect.left > 0
487
+ ? sidebarRect.left - PORTAL_GUTTER_PADDING
488
+ : window.innerWidth - PORTAL_GUTTER_PADDING;
489
+ const bottomLimit = window.innerHeight - PORTAL_GUTTER_PADDING;
490
+
491
+ let left = anchorRect.left;
492
+ let top = anchorRect.top;
493
+
494
+ if (left + width > rightLimit) {
495
+ left = anchorRect.left - width - PORTAL_CURSOR_OFFSET;
496
+ }
497
+ left = Math.max(
498
+ PORTAL_GUTTER_PADDING,
499
+ Math.min(left, rightLimit - width),
500
+ );
501
+
502
+ if (top + height > bottomLimit) {
503
+ top = bottomLimit - height;
504
+ }
505
+ top = Math.max(PORTAL_GUTTER_PADDING, top);
506
+
507
+ setPortalPosition((prev) =>
508
+ prev &&
509
+ Math.abs(prev.left - left) < 0.5 &&
510
+ Math.abs(prev.top - top) < 0.5
511
+ ? prev
512
+ : { left, top },
513
+ );
514
+ };
515
+
516
+ const schedule = () => {
517
+ if (frame) return;
518
+ frame = window.requestAnimationFrame(apply);
519
+ };
520
+
521
+ schedule();
522
+
523
+ const mutationObserver = new MutationObserver(schedule);
524
+ mutationObserver.observe(wrapper, {
525
+ attributes: true,
526
+ attributeFilter: ["style", "class"],
527
+ });
528
+
529
+ const resizeObserver =
530
+ typeof ResizeObserver === "undefined"
531
+ ? null
532
+ : new ResizeObserver(schedule);
533
+ resizeObserver?.observe(anchor);
534
+ if (portalRef.current) resizeObserver?.observe(portalRef.current);
535
+
536
+ window.addEventListener("resize", schedule);
537
+ window.addEventListener("scroll", schedule, true);
538
+
539
+ return () => {
540
+ if (frame) window.cancelAnimationFrame(frame);
541
+ mutationObserver.disconnect();
542
+ resizeObserver?.disconnect();
543
+ window.removeEventListener("resize", schedule);
544
+ window.removeEventListener("scroll", schedule, true);
545
+ };
546
+ });
547
+
548
+ if (!active || items.length === 0) return null;
549
+
550
+ const tooltip = (
551
+ <div className="min-w-40 max-w-[280px] rounded-md border border-border bg-card px-3 py-2 text-xs text-foreground shadow-lg">
456
552
  {labelText && (
457
553
  <div className="mb-1.5 truncate font-medium text-foreground">
458
554
  {labelText}
@@ -485,6 +581,32 @@ function ChartTooltip({
485
581
  </div>
486
582
  </div>
487
583
  );
584
+
585
+ return (
586
+ <>
587
+ <div ref={anchorRef} aria-hidden="true" className="invisible">
588
+ {tooltip}
589
+ </div>
590
+ {portalPosition && typeof document !== "undefined"
591
+ ? createPortal(
592
+ <div
593
+ ref={portalRef}
594
+ role="tooltip"
595
+ style={{
596
+ position: "fixed",
597
+ left: portalPosition.left,
598
+ top: portalPosition.top,
599
+ zIndex: 1000,
600
+ pointerEvents: "none",
601
+ }}
602
+ >
603
+ {tooltip}
604
+ </div>,
605
+ document.body,
606
+ )
607
+ : null}
608
+ </>
609
+ );
488
610
  }
489
611
 
490
612
  function detectKeys(
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
5
5
  import { Input } from "@/components/ui/input";
6
6
  import { Label } from "@/components/ui/label";
7
7
  import { Textarea } from "@/components/ui/textarea";
8
+ import { SqlEditor } from "@/components/SqlEditor";
8
9
  import {
9
10
  Dialog,
10
11
  DialogContent,
@@ -360,13 +361,11 @@ function PanelEditorContent({
360
361
  </Button>
361
362
  )}
362
363
  </div>
363
- <Textarea
364
+ <SqlEditor
364
365
  id="panel-sql"
365
366
  value={form.sql}
366
367
  onChange={(e) => setForm((f) => ({ ...f, sql: e.target.value }))}
367
368
  rows={10}
368
- spellCheck={false}
369
- className="font-mono text-xs resize-y min-h-[200px]"
370
369
  placeholder="SELECT ..."
371
370
  />
372
371
  <p className="text-xs text-muted-foreground">
@@ -7,8 +7,8 @@ import {
7
7
  PopoverTrigger,
8
8
  } from "@/components/ui/popover";
9
9
  import { Button } from "@/components/ui/button";
10
- import { Textarea } from "@/components/ui/textarea";
11
10
  import { Badge } from "@/components/ui/badge";
11
+ import { SqlEditor } from "@/components/SqlEditor";
12
12
  import {
13
13
  Tooltip,
14
14
  TooltipContent,
@@ -206,14 +206,14 @@ export function ViewSqlPopover({
206
206
  </div>
207
207
  </div>
208
208
 
209
- <Textarea
209
+ <SqlEditor
210
+ aria-label="SQL"
210
211
  value={draft}
211
212
  onChange={(e) => {
212
213
  if (canEditSql) setDraft(e.target.value);
213
214
  }}
214
215
  rows={12}
215
- spellCheck={false}
216
- className="font-mono text-xs resize-y min-h-[240px]"
216
+ className="min-h-[240px]"
217
217
  placeholder="SELECT ..."
218
218
  readOnly={!canEditSql}
219
219
  />
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ Active-user dashboard panels classify templates more accurately instead of overusing the unknown bucket.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ Dashboard panel deletes now stay deleted after refresh.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ SQL panel editors now highlight query syntax and keep one-click formatting available.
@@ -428,7 +428,7 @@
428
428
  "chartType": "area",
429
429
  "source": "first-party",
430
430
  "width": 2,
431
- "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') ORDER BY date, template",
431
+ "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') ORDER BY date, template",
432
432
  "config": {
433
433
  "xKey": "date",
434
434
  "yKey": "users",
@@ -448,7 +448,7 @@
448
448
  "chartType": "area",
449
449
  "source": "first-party",
450
450
  "width": 2,
451
- "sql": "WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT d.date, b.template, COUNT(DISTINCT b.user_key) AS users FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template",
451
+ "sql": "WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT d.date, b.template, COUNT(DISTINCT b.user_key) AS users FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template",
452
452
  "config": {
453
453
  "xKey": "date",
454
454
  "yKey": "users",
@@ -96,7 +96,7 @@ function fixed(sql: string): (window?: MetricWindow) => string {
96
96
  return () => sql;
97
97
  }
98
98
 
99
- const SIGNUP_TEMPLATE_EXPR =
99
+ const TEMPLATE_EXPR =
100
100
  "COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')";
101
101
  const DASHBOARD_TIME_RANGE_FILTER =
102
102
  "('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))";
@@ -142,10 +142,12 @@ export function usesFirstPartyDashboardFilters(sql: string): boolean {
142
142
  }
143
143
 
144
144
  const TOTAL_SIGNUPS_SQL = `SELECT COUNT(*) AS signups FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}`;
145
- const SIGNUPS_OVER_TIME_SQL = `WITH offsets AS (SELECT (ROW_NUMBER() OVER (ORDER BY timestamp) - 1)::int AS n FROM analytics_events LIMIT 800), signup_events AS (SELECT substr(timestamp, 1, 10) AS date, ${SIGNUP_TEMPLATE_EXPR} AS template FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template`;
146
- const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${SIGNUP_TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, template, MIN(event_date) AS cohort_date FROM base GROUP BY user_key, template), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '1 day', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT c.cohort_date AS date, c.template, COALESCE(COUNT(DISTINCT c.user_key) FILTER (WHERE b.event_date = to_char(c.cohort_date::date + INTERVAL '1 day', 'YYYY-MM-DD'))::float / NULLIF(COUNT(DISTINCT c.user_key), 0), 0) AS rate FROM cohorts c LEFT JOIN base b ON b.user_key = c.user_key AND b.template = c.template GROUP BY c.cohort_date, c.template ORDER BY c.cohort_date, c.template`;
147
- const SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${SIGNUP_TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, template, MIN(event_date) AS cohort_date FROM base GROUP BY user_key, template), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT c.cohort_date AS date, c.template, COALESCE(COUNT(DISTINCT c.user_key) FILTER (WHERE b.event_date = to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD'))::float / NULLIF(COUNT(DISTINCT c.user_key), 0), 0) AS rate FROM cohorts c LEFT JOIN base b ON b.user_key = c.user_key AND b.template = c.template GROUP BY c.cohort_date, c.template ORDER BY c.cohort_date, c.template`;
148
- const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${SIGNUP_TEMPLATE_EXPR} AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${SIGNUP_TEMPLATE_EXPR} ORDER BY count DESC`;
145
+ const SIGNUPS_OVER_TIME_SQL = `WITH offsets AS (SELECT (ROW_NUMBER() OVER (ORDER BY timestamp) - 1)::int AS n FROM analytics_events LIMIT 800), signup_events AS (SELECT substr(timestamp, 1, 10) AS date, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template`;
146
+ const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, template, MIN(event_date) AS cohort_date FROM base GROUP BY user_key, template), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '1 day', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT c.cohort_date AS date, c.template, COALESCE(COUNT(DISTINCT c.user_key) FILTER (WHERE b.event_date = to_char(c.cohort_date::date + INTERVAL '1 day', 'YYYY-MM-DD'))::float / NULLIF(COUNT(DISTINCT c.user_key), 0), 0) AS rate FROM cohorts c LEFT JOIN base b ON b.user_key = c.user_key AND b.template = c.template GROUP BY c.cohort_date, c.template ORDER BY c.cohort_date, c.template`;
147
+ const SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, template, MIN(event_date) AS cohort_date FROM base GROUP BY user_key, template), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT c.cohort_date AS date, c.template, COALESCE(COUNT(DISTINCT c.user_key) FILTER (WHERE b.event_date = to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD'))::float / NULLIF(COUNT(DISTINCT c.user_key), 0), 0) AS rate FROM cohorts c LEFT JOIN base b ON b.user_key = c.user_key AND b.template = c.template GROUP BY c.cohort_date, c.template ORDER BY c.cohort_date, c.template`;
148
+ const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${TEMPLATE_EXPR} AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${TEMPLATE_EXPR} ORDER BY count DESC`;
149
+ const DAU_BY_TEMPLATE_SQL = `SELECT substr(timestamp, 1, 10) AS date, ${TEMPLATE_EXPR} AS template, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY substr(timestamp, 1, 10), ${TEMPLATE_EXPR} ORDER BY date, template`;
150
+ const WAU_BY_TEMPLATE_SQL = `WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, ${TEMPLATE_EXPR} AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ${DASHBOARD_EMAIL_FILTER}), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ${DASHBOARD_TIME_RANGE_FILTER.split("substr(timestamp, 1, 10)").join("event_date")}) SELECT d.date, b.template, COUNT(DISTINCT b.user_key) AS users FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template`;
149
151
 
150
152
  /**
151
153
  * Catalog entries. Order here is the default panel order when a caller passes
@@ -578,9 +580,7 @@ const ENTRIES: FirstPartyMetric[] = [
578
580
  source: "first-party",
579
581
  width: 2,
580
582
  windowed: false,
581
- buildSql: fixed(
582
- "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') ORDER BY date, template",
583
- ),
583
+ buildSql: fixed(DAU_BY_TEMPLATE_SQL),
584
584
  config: {
585
585
  xKey: "date",
586
586
  yKey: "users",
@@ -602,9 +602,7 @@ const ENTRIES: FirstPartyMetric[] = [
602
602
  source: "first-party",
603
603
  width: 2,
604
604
  windowed: false,
605
- buildSql: fixed(
606
- "WITH base AS (SELECT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) AS user_key, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, substr(timestamp, 1, 10) AS event_date, user_id FROM analytics_events WHERE COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, '')) IS NOT NULL AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) SELECT d.date, b.template, COUNT(DISTINCT b.user_key) AS users FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template",
607
- ),
605
+ buildSql: fixed(WAU_BY_TEMPLATE_SQL),
608
606
  config: {
609
607
  xKey: "date",
610
608
  yKey: "users",
@@ -55,6 +55,15 @@ export default defineAction({
55
55
  .describe("Sort order"),
56
56
  limit: z.coerce.number().int().min(1).max(500).default(100),
57
57
  offset: z.coerce.number().int().min(0).default(0),
58
+ countOnly: z
59
+ // Robust coercion: a GET query param arrives as the string "true"/"false",
60
+ // and z.coerce.boolean would treat "false" as true. Map strings explicitly.
61
+ .preprocess(
62
+ (v) => (typeof v === "string" ? v === "true" : v),
63
+ z.boolean(),
64
+ )
65
+ .default(false)
66
+ .describe("Return only the total count, skipping the row payload"),
58
67
  }),
59
68
  http: { method: "GET" },
60
69
  run: async (args) => {
@@ -135,6 +144,18 @@ export default defineAction({
135
144
  );
136
145
  }
137
146
 
147
+ // Count-only callers (e.g. the sidebar badge) need just the total for the
148
+ // same filters, ignoring limit/offset. Run the COUNT and short-circuit
149
+ // before the row select, joins, and tag/view subqueries. Keeping it inside
150
+ // this branch means the normal list path doesn't pay for an extra query.
151
+ if (args.countOnly) {
152
+ const totalRows = await db
153
+ .select({ count: sql<number>`COUNT(1)` })
154
+ .from(schema.recordings)
155
+ .where(and(...whereClauses));
156
+ return { recordings: [], total: Number(totalRows[0]?.count ?? 0) };
157
+ }
158
+
138
159
  // Sort
139
160
  const viewCountOrder = sql<number>`(
140
161
  SELECT COUNT(1)
@@ -46,6 +46,7 @@ import {
46
46
  useSpaces,
47
47
  useOrganizations,
48
48
  useCreateFolder,
49
+ useRecordingsCount,
49
50
  } from "@/hooks/use-library";
50
51
  import { useIsMobile } from "@/hooks/use-mobile";
51
52
  import { useDesktopPromo } from "@/hooks/use-desktop-promo";
@@ -124,6 +125,10 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
124
125
  { enabled: hasActiveOrg && Boolean(currentOrganizationId) },
125
126
  );
126
127
 
128
+ // Clip count for the "Library" nav item — count-only, no row payload or
129
+ // title polling across the app shell.
130
+ const { data: libraryCount } = useRecordingsCount({ view: "library" });
131
+
127
132
  const libFolderList: FolderNode[] = useMemo(
128
133
  () =>
129
134
  (libFolders?.folders ?? [])
@@ -176,12 +181,14 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
176
181
  label: string;
177
182
  icon: React.ComponentType<{ className?: string }>;
178
183
  match: (path: string) => boolean;
184
+ count?: number;
179
185
  }[] = [
180
186
  {
181
187
  to: "/library",
182
188
  label: "Library",
183
189
  icon: IconInbox,
184
190
  match: (p) => p.startsWith("/library"),
191
+ count: libraryCount,
185
192
  },
186
193
  {
187
194
  to: "/spaces",
@@ -374,7 +381,7 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
374
381
  </div>
375
382
 
376
383
  <nav className="mt-3 space-y-0.5 px-2">
377
- {navItems.map(({ to, label, icon: Icon, match }) => {
384
+ {navItems.map(({ to, label, icon: Icon, match, count }) => {
378
385
  const active = match(location.pathname);
379
386
  return (
380
387
  <NavLink
@@ -388,7 +395,19 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
388
395
  )}
389
396
  >
390
397
  <Icon className="h-4 w-4" />
391
- {label}
398
+ <span className="flex-1 truncate">{label}</span>
399
+ {count !== undefined && count > 0 && (
400
+ <span
401
+ className={cn(
402
+ "shrink-0 tabular-nums text-[11px]",
403
+ active
404
+ ? "text-primary/80"
405
+ : "text-muted-foreground",
406
+ )}
407
+ >
408
+ {count}
409
+ </span>
410
+ )}
392
411
  </NavLink>
393
412
  );
394
413
  })}
@@ -87,6 +87,24 @@ export function useRecordings(args: ListRecordingsArgs = {}) {
87
87
  );
88
88
  }
89
89
 
90
+ /**
91
+ * Count-only variant for surfaces like the sidebar badge that need a total but
92
+ * not the rows. Hits `list-recordings` with `countOnly`, so it skips the row
93
+ * payload server-side and doesn't share (or pay for) the full-list query or its
94
+ * title polling.
95
+ */
96
+ export function useRecordingsCount(
97
+ args: Omit<ListRecordingsArgs, "limit" | "offset"> = {},
98
+ ) {
99
+ return useActionQuery<number>(
100
+ "list-recordings",
101
+ { ...args, countOnly: true } as any,
102
+ {
103
+ select: (data: any) => (typeof data?.total === "number" ? data.total : 0),
104
+ },
105
+ );
106
+ }
107
+
90
108
  export interface SearchHit {
91
109
  id: string;
92
110
  title: string;
@@ -489,14 +489,16 @@ sign-in at setup — this is intended), so the first tool call in that client do
489
489
  not hit an OAuth wall:
490
490
 
491
491
  ```bash
492
- npx @agent-native/core@latest skills add visual-plan
492
+ npx @agent-native/core@latest skills add visual-plans
493
493
  ```
494
494
 
495
495
  After that, `/visual-plan` and `/visual-recap` are the two installed slash
496
- commands. The other planning modes (`create-ui-plan`, `create-prototype-plan`,
497
- `create-plan-design`, `create-visual-questions`) are MCP tools reachable from
498
- `/visual-plan`, not separate slash commands. Pass `--no-connect` to register
499
- the connector without authenticating, then run
496
+ commands. If you only need one command, use `skills add visual-plan` or
497
+ `skills add visual-recap` instead. The other planning modes
498
+ (`create-ui-plan`, `create-prototype-plan`, `create-plan-design`,
499
+ `create-visual-questions`) are MCP tools reachable from `/visual-plan`, not
500
+ separate slash commands. Pass `--no-connect` to register the connector without
501
+ authenticating, then run
500
502
  `npx @agent-native/core@latest connect https://plan.agent-native.com --client all`
501
503
  whenever you are ready, or choose a narrower `--client`. Auth and MCP tool
502
504
  loading are per client config/session.
@@ -4,6 +4,50 @@
4
4
  * function, so it inherits the 15-min budget.
5
5
  */
6
6
  export declare const AGENT_CHAT_PROCESS_RUN_PATH = "/_agent-native/agent-chat/_process-run";
7
+ /**
8
+ * Name of the standalone Netlify background function the build emits (see
9
+ * `emitSingleTemplateNetlifyBackgroundFunction` in deploy/build.ts). Shared so
10
+ * the emit and the dispatch-path helper below can never drift on the name.
11
+ *
12
+ * MUST end in `-background` — both because that is the conventional Netlify
13
+ * async-function suffix and because `isInBackgroundFunctionRuntime()` reads the
14
+ * `AWS_LAMBDA_FUNCTION_NAME` `-background` suffix as a secondary runtime signal.
15
+ */
16
+ export declare const AGENT_BACKGROUND_FUNCTION_NAME = "server-agent-background";
17
+ /**
18
+ * Direct invocation URL of the standalone background function on Netlify.
19
+ *
20
+ * The function is emitted into the STANDARD functions dir with `background:true`
21
+ * and NO custom `config.path`, so Netlify exposes it (and ONLY it) at the
22
+ * default function URL `/.netlify/functions/<name>` and invokes it
23
+ * asynchronously (immediate HTTP 202 ack, 15-min budget). Hitting this URL
24
+ * directly BYPASSES Nitro's `/*` catch-all `server` function — which is the
25
+ * whole point: the previous approach put a custom `config.path` on the function
26
+ * and Netlify routed `/_agent-native/agent-chat/_process-run` to the synchronous
27
+ * `server` catch-all instead (verified live: a synchronous 401 from the handler
28
+ * rather than a 202 async ack).
29
+ */
30
+ export declare const AGENT_BACKGROUND_FUNCTION_URL_PATH = "/.netlify/functions/server-agent-background";
31
+ /**
32
+ * Resolve the path the foreground POST should self-dispatch the chat background
33
+ * worker to.
34
+ *
35
+ * On hosted Netlify (`NETLIFY` truthy and not `netlify dev`) the standalone
36
+ * async background function is reachable ONLY at its direct function URL
37
+ * (`/.netlify/functions/server-agent-background`); POSTing the framework route
38
+ * `AGENT_CHAT_PROCESS_RUN_PATH` would land on Nitro's synchronous `/*` catch-all
39
+ * (the ~60s `server` function) and NEVER get the 15-min budget. The standalone
40
+ * function's entry rewrites the incoming path back to `AGENT_CHAT_PROCESS_RUN_PATH`
41
+ * before delegating to the Nitro handler, so the `_process-run` plugin still
42
+ * dispatches it — only the OUTER URL differs.
43
+ *
44
+ * Everywhere else (local dev, `netlify dev`, non-Netlify hosts where the
45
+ * standalone function does not exist) we keep dispatching to the framework route
46
+ * directly — the same in-process catch-all handles it, and there is no separate
47
+ * async function to reach. The HMAC token (signed over the runId) is unchanged
48
+ * either way; only the URL path differs.
49
+ */
50
+ export declare function resolveAgentChatProcessRunDispatchPath(): string;
7
51
  /**
8
52
  * Env flag for durable background runs. DEFAULT-ON: unset means enabled; an app
9
53
  * opts OUT with an explicit falsy value (`false`/`0`/`no`/`off`).
@@ -39,9 +83,13 @@ export declare function isHostedRuntimeForDurableBackground(): boolean;
39
83
  * hard wall and get killed at 60s, then re-dispatch/resume in a wasteful loop.
40
84
  * So the 13-min budget is taken ONLY when this returns true.
41
85
  *
42
- * Falls back to the explicit `AGENT_CHAT_FORCE_BACKGROUND_RUNTIME` env (truthy)
43
- * for hosts that don't expose a `-background`-suffixed function name but where
44
- * the operator has confirmed a long async budget. Off by default.
86
+ * The PRIMARY signal is a `globalThis` marker the emitted background function's
87
+ * entry sets at cold start the deployed Lambda name is not guaranteed to end
88
+ * in `-background` on Netlify, so the entry marks its own runtime. A `globalThis`
89
+ * flag (not `process.env`) keeps the no-env-mutation guard satisfied and carries
90
+ * no cross-request state (set once per isolate). The `AWS_LAMBDA_FUNCTION_NAME`
91
+ * suffix and the explicit `AGENT_CHAT_FORCE_BACKGROUND_RUNTIME` env (truthy) are
92
+ * additional signals — the latter an operator escape hatch. Off by default.
45
93
  */
46
94
  export declare function isInBackgroundFunctionRuntime(): boolean;
47
95
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"durable-background.d.ts","sourceRoot":"","sources":["../../src/agent/durable-background.ts"],"names":[],"mappings":"AA6CA;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,2CACE,CAAC;AAE3C;;;GAGG;AACH,eAAO,MAAM,iCAAiC,kCACb,CAAC;AAElC;;;;;;GAMG;AACH,eAAO,MAAM,+BAA+B,oBAAoB,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,mCAAmC,IAAI,OAAO,CAsB7D;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,6BAA6B,IAAI,OAAO,CAcvD;AAyBD;;;;;;GAMG;AACH,wBAAgB,mCAAmC,IAAI,OAAO,CAM7D;AAED,uDAAuD;AACvD,MAAM,MAAM,qBAAqB,GAC7B;IACE,EAAE,EAAE,IAAI,CAAC;IACT,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,GACD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEN;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,OAAO,EACb,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,qBAAqB,CA0CvB"}
1
+ {"version":3,"file":"durable-background.d.ts","sourceRoot":"","sources":["../../src/agent/durable-background.ts"],"names":[],"mappings":"AA6CA;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,2CACE,CAAC;AAE3C;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,4BAA4B,CAAC;AAExE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kCAAkC,gDAA0D,CAAC;AAE1G;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sCAAsC,IAAI,MAAM,CAS/D;AAED;;;GAGG;AACH,eAAO,MAAM,iCAAiC,kCACb,CAAC;AAElC;;;;;;GAMG;AACH,eAAO,MAAM,+BAA+B,oBAAoB,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,mCAAmC,IAAI,OAAO,CAsB7D;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,6BAA6B,IAAI,OAAO,CAsBvD;AAyBD;;;;;;GAMG;AACH,wBAAgB,mCAAmC,IAAI,OAAO,CAM7D;AAED,uDAAuD;AACvD,MAAM,MAAM,qBAAqB,GAC7B;IACE,EAAE,EAAE,IAAI,CAAC;IACT,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,GACD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEN;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,OAAO,EACb,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,qBAAqB,CA0CvB"}