@agent-native/core 0.76.12 → 0.76.13

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.
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 1929
31
- - template files: 4250
31
+ - template files: 4251
@@ -1,5 +1,17 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.76.13
4
+
5
+ ### Patch Changes
6
+
7
+ - d12772c: Durable background agent-chat: raise the foreground circuit-breaker's claim grace
8
+ from 8s to 15s (`BACKGROUND_CLAIM_GRACE_MS`). Heavy apps (observed on analytics
9
+ in prod) take longer than 8s to cold-start the background function and reach
10
+ `claimBackgroundRun`, so the foreground recovered inline every time — adding ~8s
11
+ latency per turn and never using the 15-minute background budget. 15s lets the
12
+ slow-but-alive workers win the claim while staying well within the foreground's
13
+ ~40s soft-timeout; a genuinely dead worker still falls back to inline.
14
+
3
15
  ## 0.76.12
4
16
 
5
17
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.76.12",
3
+ "version": "0.76.13",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -153,11 +153,14 @@ export { PROVIDER_TO_ENV };
153
153
  /**
154
154
  * Grace window + poll interval for the foreground circuit-breaker that confirms
155
155
  * a background worker actually CLAIMED a 202-dispatched run before recovering
156
- * inline. The grace is long enough for a cold-start worker to win the claim
157
- * (~1-2s typical) and short enough to recover quickly within the foreground's
158
- * ~40s soft-timeout.
156
+ * inline. The grace must cover the worker's cold-start + per-request init before
157
+ * it reaches `claimBackgroundRun`: light apps win the claim in ~1-2s, but heavy
158
+ * apps (e.g. analytics) were observed in prod taking >8s, so an 8s grace made
159
+ * their worker lose the race every time and always fall back to inline (adding
160
+ * ~8s latency with no background budget). 15s covers the slow apps while staying
161
+ * well within the foreground's ~40s soft-timeout.
159
162
  */
160
- export const BACKGROUND_CLAIM_GRACE_MS = 8_000;
163
+ export const BACKGROUND_CLAIM_GRACE_MS = 15_000;
161
164
  export const BACKGROUND_CLAIM_POLL_MS = 400;
162
165
 
163
166
  export type BackgroundDispatchOutcome =
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { cn } from "@/lib/utils";
5
+ import {
6
+ createBackgroundBlurStream,
7
+ DEFAULT_BLUR_PX,
8
+ type CameraBlurHandle,
9
+ } from "@/lib/camera-blur";
5
10
  import type { CameraBubbleSize } from "./camera-bubble";
6
11
 
7
12
  export type CameraTestStatus = "idle" | "starting" | "live" | "error";
@@ -10,6 +15,10 @@ export interface CameraVisualizerProps {
10
15
  deviceId: string | null;
11
16
  disabled?: boolean;
12
17
  className?: string;
18
+ /** Mirror the recording's background-blur setting in the live test preview. */
19
+ blur?: boolean;
20
+ /** Background blur radius (px) reflected live in the test preview. */
21
+ blurRadius?: number;
13
22
  size?: CameraBubbleSize;
14
23
  onSizeChange?: (size: CameraBubbleSize) => void;
15
24
  onStatusChange?: (
@@ -119,6 +128,8 @@ export function CameraVisualizer({
119
128
  deviceId,
120
129
  disabled,
121
130
  className,
131
+ blur = false,
132
+ blurRadius = DEFAULT_BLUR_PX,
122
133
  size = "md",
123
134
  onSizeChange,
124
135
  onStatusChange,
@@ -126,8 +137,14 @@ export function CameraVisualizer({
126
137
  }: CameraVisualizerProps) {
127
138
  const videoRef = useRef<HTMLVideoElement | null>(null);
128
139
  const streamRef = useRef<MediaStream | null>(null);
140
+ const blurHandleRef = useRef<CameraBlurHandle | null>(null);
141
+ // Bumped per attachPreview() so a stale segmenter build (blur toggled mid-load)
142
+ // bails instead of clobbering the preview.
143
+ const attachGenRef = useRef(0);
144
+ const blurRadiusRef = useRef(blurRadius);
129
145
  const runIdRef = useRef(0);
130
146
  const previousDeviceIdRef = useRef(deviceId);
147
+ const previousBlurRef = useRef(blur);
131
148
 
132
149
  const [status, setStatus] = useState<CameraTestStatus>("idle");
133
150
  const [error, setError] = useState<string | null>(null);
@@ -144,11 +161,45 @@ export function CameraVisualizer({
144
161
  }, [onPreviewChange]);
145
162
 
146
163
  const stopCurrent = useCallback(() => {
164
+ blurHandleRef.current?.cleanup();
165
+ blurHandleRef.current = null;
147
166
  stopStream(streamRef.current);
148
167
  streamRef.current = null;
149
168
  clearVideo();
150
169
  }, [clearVideo]);
151
170
 
171
+ // Bind the raw camera or its blurred derivative to the <video> per the current
172
+ // `blur` setting, so the preview matches what recording bakes in. Each call
173
+ // claims a generation and bails if a newer attach superseded it during an await.
174
+ const attachPreview = useCallback(async () => {
175
+ const gen = ++attachGenRef.current;
176
+ const raw = streamRef.current;
177
+ const video = videoRef.current;
178
+ if (!raw || !video) return;
179
+
180
+ let display: MediaStream = raw;
181
+ if (blur) {
182
+ blurHandleRef.current?.cleanup();
183
+ blurHandleRef.current = null;
184
+ const handle = await createBackgroundBlurStream(raw, {
185
+ blurPx: blurRadiusRef.current,
186
+ });
187
+ if (gen !== attachGenRef.current || streamRef.current !== raw) {
188
+ handle.cleanup();
189
+ return;
190
+ }
191
+ blurHandleRef.current = handle;
192
+ display = handle.stream;
193
+ } else {
194
+ blurHandleRef.current?.cleanup();
195
+ blurHandleRef.current = null;
196
+ }
197
+
198
+ if (gen !== attachGenRef.current) return;
199
+ if (video.srcObject !== display) video.srcObject = display;
200
+ await video.play().catch(() => {});
201
+ }, [blur]);
202
+
152
203
  const stopTest = useCallback(() => {
153
204
  runIdRef.current += 1;
154
205
  stopCurrent();
@@ -216,12 +267,15 @@ export function CameraVisualizer({
216
267
  }
217
268
 
218
269
  streamRef.current = stream;
219
- const video = videoRef.current;
220
- if (video) {
221
- video.srcObject = stream;
222
- await video.play().catch(() => {});
270
+ // Webcam unplugged mid-test: tear down so the preview + blur pipeline
271
+ // don't keep running frozen. runId guard skips our own stop().
272
+ for (const track of stream.getVideoTracks()) {
273
+ track.addEventListener("ended", () => {
274
+ if (runIdRef.current === runId) stopTest();
275
+ });
223
276
  }
224
- // Re-check after play()'s await so a newer startTest can't be clobbered.
277
+ await attachPreview();
278
+ // Re-check after the async attach so a newer startTest can't be clobbered.
225
279
  if (runIdRef.current !== runId) {
226
280
  stopCurrent();
227
281
  return;
@@ -239,7 +293,15 @@ export function CameraVisualizer({
239
293
  onStatusChange?.("error", { error: message });
240
294
  clearVideo();
241
295
  }
242
- }, [clearVideo, deviceId, disabled, onStatusChange, stopCurrent]);
296
+ }, [
297
+ attachPreview,
298
+ clearVideo,
299
+ deviceId,
300
+ disabled,
301
+ onStatusChange,
302
+ stopCurrent,
303
+ stopTest,
304
+ ]);
243
305
 
244
306
  useEffect(() => {
245
307
  if (disabled) {
@@ -270,10 +332,10 @@ export function CameraVisualizer({
270
332
  useEffect(() => {
271
333
  if (status !== "live" && status !== "starting") return;
272
334
  const video = videoRef.current;
273
- const stream = streamRef.current;
274
- if (!video || !stream) return;
275
- if (video.srcObject !== stream) {
276
- video.srcObject = stream;
335
+ const display = blurHandleRef.current?.stream ?? streamRef.current;
336
+ if (!video || !display) return;
337
+ if (video.srcObject !== display) {
338
+ video.srcObject = display;
277
339
  }
278
340
  const tryPlay = () => {
279
341
  video.play().catch(() => undefined);
@@ -285,6 +347,22 @@ export function CameraVisualizer({
285
347
  };
286
348
  }, [status]);
287
349
 
350
+ // Toggle blur while live: swap the preview source in place (startTest already
351
+ // binds the initial value, so skip mount).
352
+ useEffect(() => {
353
+ if (previousBlurRef.current === blur) return;
354
+ previousBlurRef.current = blur;
355
+ if (status !== "live" && status !== "starting") return;
356
+ if (!streamRef.current) return;
357
+ void attachPreview();
358
+ }, [blur, status, attachPreview]);
359
+
360
+ // Slider drags adjust the live pipeline without rebuilding the segmenter.
361
+ useEffect(() => {
362
+ blurRadiusRef.current = blurRadius;
363
+ blurHandleRef.current?.setBlurPx(blurRadius);
364
+ }, [blurRadius]);
365
+
288
366
  const live = status === "live";
289
367
  const starting = status === "starting";
290
368
  const showBubble = live || starting;
@@ -7,6 +7,7 @@ import {
7
7
  type FormEvent,
8
8
  } from "react";
9
9
  import {
10
+ IconBlur,
10
11
  IconBrowser,
11
12
  IconCamera,
12
13
  IconChevronDown,
@@ -40,6 +41,7 @@ import {
40
41
  SelectValue,
41
42
  } from "@/components/ui/select";
42
43
  import { Switch } from "@/components/ui/switch";
44
+ import { Slider } from "@/components/ui/slider";
43
45
  import { cn } from "@/lib/utils";
44
46
  import {
45
47
  NO_CAMERA_DEVICE_ID,
@@ -48,6 +50,7 @@ import {
48
50
  type RecordingMode,
49
51
  } from "./recorder-engine";
50
52
  import type { CameraBubbleSize } from "./camera-bubble";
53
+ import { DEFAULT_BLUR_PX, MAX_BLUR_PX, MIN_BLUR_PX } from "@/lib/camera-blur";
51
54
  import {
52
55
  loadRecorderPreferences,
53
56
  saveRecorderPreferences,
@@ -65,6 +68,8 @@ export interface PreRecordPanelProps {
65
68
  displaySurface: DisplaySurface;
66
69
  micDeviceId: string | null;
67
70
  cameraDeviceId: string | null;
71
+ cameraBlur: boolean;
72
+ cameraBlurRadius: number;
68
73
  }) => void;
69
74
  initialMode?: RecordingMode | null;
70
75
  initialDisplaySurface?: DisplaySurface | null;
@@ -210,6 +215,12 @@ export function PreRecordPanel({
210
215
  const [cameraId, setCameraId] = useState<string>(
211
216
  () => savedPrefs.cameraId ?? "default",
212
217
  );
218
+ const [cameraBlur, setCameraBlur] = useState(
219
+ () => savedPrefs.cameraBlur ?? false,
220
+ );
221
+ const [cameraBlurRadius, setCameraBlurRadius] = useState(
222
+ () => savedPrefs.cameraBlurRadius ?? DEFAULT_BLUR_PX,
223
+ );
213
224
  const [enumError, setEnumError] = useState<string | null>(null);
214
225
  const [micAccessStatus, setMicAccessStatus] =
215
226
  useState<DeviceAccessStatus>("idle");
@@ -335,6 +346,10 @@ export function PreRecordPanel({
335
346
  setCameraId(value);
336
347
  saveRecorderPreferences({ cameraId: value });
337
348
  }, []);
349
+ const chooseCameraBlur = useCallback((value: boolean) => {
350
+ setCameraBlur(value);
351
+ saveRecorderPreferences({ cameraBlur: value });
352
+ }, []);
338
353
 
339
354
  const requestMicrophoneChoices = useCallback(async () => {
340
355
  if (!navigator.mediaDevices?.getUserMedia) {
@@ -826,12 +841,59 @@ export function PreRecordPanel({
826
841
  <CameraVisualizer
827
842
  deviceId={cameraId === "default" ? null : cameraId}
828
843
  disabled={busy}
844
+ blur={cameraBlur}
845
+ blurRadius={cameraBlurRadius}
829
846
  size={cameraSize}
830
847
  onSizeChange={onCameraSizeChange}
831
848
  onStatusChange={handleCameraStatusChange}
832
849
  onPreviewChange={handleCameraPreviewChange}
833
850
  />
834
851
  ) : null}
852
+
853
+ {needsCamera ? (
854
+ <label className="flex cursor-pointer items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-muted/45">
855
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
856
+ <IconBlur className="h-4 w-4" />
857
+ </div>
858
+ <div className="min-w-0 flex-1">
859
+ <p className="text-sm">Blur background</p>
860
+ <p className="text-[11px] text-muted-foreground">
861
+ Keep yourself sharp, blur what's behind you
862
+ </p>
863
+ </div>
864
+ <Switch
865
+ checked={cameraBlur}
866
+ onCheckedChange={chooseCameraBlur}
867
+ disabled={busy}
868
+ aria-label="Blur the camera background"
869
+ />
870
+ </label>
871
+ ) : null}
872
+
873
+ {needsCamera && cameraBlur ? (
874
+ <div className="flex items-center gap-3 px-1 pb-1">
875
+ <span className="w-14 shrink-0 text-[11px] text-muted-foreground">
876
+ Intensity
877
+ </span>
878
+ <Slider
879
+ value={[cameraBlurRadius]}
880
+ min={MIN_BLUR_PX}
881
+ max={MAX_BLUR_PX}
882
+ step={1}
883
+ disabled={busy}
884
+ onValueChange={(value) =>
885
+ setCameraBlurRadius(value[0] ?? DEFAULT_BLUR_PX)
886
+ }
887
+ onValueCommit={(value) =>
888
+ saveRecorderPreferences({
889
+ cameraBlurRadius: value[0] ?? DEFAULT_BLUR_PX,
890
+ })
891
+ }
892
+ aria-label="Background blur intensity"
893
+ className="flex-1"
894
+ />
895
+ </div>
896
+ ) : null}
835
897
  </div>
836
898
  ) : null}
837
899
 
@@ -870,6 +932,8 @@ export function PreRecordPanel({
870
932
  micDeviceId: micId === "default" ? null : micId,
871
933
  cameraDeviceId:
872
934
  needsCamera && cameraId !== "default" ? cameraId : null,
935
+ cameraBlur: needsCamera ? cameraBlur : false,
936
+ cameraBlurRadius,
873
937
  })
874
938
  }
875
939
  className={cn("h-12 gap-2", onCancel ? "flex-1" : "w-full")}
@@ -19,6 +19,10 @@ import {
19
19
  createCameraCompositeStream,
20
20
  type CameraCompositeHandle,
21
21
  } from "@/lib/camera-composite";
22
+ import {
23
+ createBackgroundBlurStream,
24
+ type CameraBlurHandle,
25
+ } from "@/lib/camera-blur";
22
26
  import {
23
27
  chunkUploadUrl,
24
28
  pickMimeType,
@@ -75,6 +79,15 @@ export interface RecorderEngineOptions {
75
79
  cameraDeviceId?: string | null;
76
80
  /** Camera bubble size selected in the pre-record UI. */
77
81
  cameraBubbleSize?: "sm" | "md" | "lg";
82
+ /**
83
+ * Blur the camera background (sharp person, blurred surroundings) for both the
84
+ * live preview bubble and the baked-in recording composite. Resolved at
85
+ * `acquire()` time. Silently no-ops (records un-blurred) if segmentation is
86
+ * unavailable in the browser.
87
+ */
88
+ cameraBlur?: boolean;
89
+ /** Background blur radius in px when `cameraBlur` is on. Defaults to ~12. */
90
+ cameraBlurRadius?: number;
78
91
  /** Chunk size in ms (MediaRecorder timeslice). Default 2000. */
79
92
  chunkIntervalMs?: number;
80
93
  /** Base URL for the chunk upload endpoint. Default `/api/uploads/:id/chunk`. */
@@ -99,6 +112,11 @@ export interface RecorderEngineOptions {
99
112
  * `onError`, this does NOT transition the engine into the `error` state.
100
113
  */
101
114
  onWarning?: (message: string) => void;
115
+ /**
116
+ * Fired when the camera ends (unplugged / revoked) any time after acquire,
117
+ * so the UI can drop the on-page camera bubble to match the recorded output.
118
+ */
119
+ onCameraEnded?: () => void;
102
120
  /**
103
121
  * Called when the display stream's video track ends because the user clicked
104
122
  * the browser's native "Stop sharing" button. When provided, the engine
@@ -360,6 +378,17 @@ export class RecorderEngine {
360
378
 
361
379
  private displayStream: MediaStream | null = null;
362
380
  private cameraStream: MediaStream | null = null;
381
+ /**
382
+ * Raw getUserMedia camera stream. When background blur is active,
383
+ * `cameraStream` points at the processed (blurred) derivative and this field
384
+ * keeps the original so teardown stops the real camera tracks and the
385
+ * disconnect handler can observe the hardware ending.
386
+ */
387
+ private rawCameraStream: MediaStream | null = null;
388
+ private cameraBlur: CameraBlurHandle | null = null;
389
+ // True once the camera is acquired, through preview/countdown/recording, until
390
+ // teardown — gates disconnect handling outside the recording state.
391
+ private cameraLive = false;
363
392
  private micStream: MediaStream | null = null;
364
393
  private combinedStream: MediaStream | null = null;
365
394
  private previewStream: MediaStream | null = null;
@@ -697,13 +726,10 @@ export class RecorderEngine {
697
726
  }
698
727
  }
699
728
 
700
- // Camera / mic disconnects mid-recording (USB webcam unplugged, mic
701
- // permission revoked, a Bluetooth input dropping) are NON-fatal: the
702
- // recording continues with whatever inputs remain, and we surface a
703
- // non-blocking warning. We use the same recording/paused guard as the
704
- // display handler so the `ended` events fired by `cleanupTracks()` during
705
- // a normal stop()/cancel() (state is `stopping`/`idle` by then) are
706
- // ignored rather than treated as a disconnect.
729
+ // Camera / mic disconnects (USB webcam unplugged, permission revoked,
730
+ // Bluetooth dropped) are NON-fatal: keep whatever inputs remain and warn.
731
+ // The handlers gate on `cameraLive`/state so the `ended` events fired by
732
+ // `cleanupTracks()` during a normal stop/cancel are ignored.
707
733
  if (this.cameraStream) {
708
734
  for (const track of this.cameraStream.getVideoTracks()) {
709
735
  track.addEventListener("ended", () => {
@@ -720,6 +746,37 @@ export class RecorderEngine {
720
746
  }
721
747
  }
722
748
 
749
+ // Camera is live from here through preview/countdown/recording until
750
+ // teardown — set before the async blur setup so a disconnect during that
751
+ // window is handled, not inherited as a dead stream.
752
+ if (this.cameraStream) this.cameraLive = true;
753
+
754
+ // Swap the raw camera for its blurred derivative, which both the preview
755
+ // bubble and the recording composite read ("what you see is what's
756
+ // recorded"). createBackgroundBlurStream never throws — it falls back to
757
+ // the raw stream on failure.
758
+ if (this.opts.cameraBlur && this.cameraStream) {
759
+ this.rawCameraStream = this.cameraStream;
760
+ const handle = await createBackgroundBlurStream(this.cameraStream, {
761
+ blurPx: this.opts.cameraBlurRadius,
762
+ });
763
+ if (this.cameraDisconnectNotified) {
764
+ // Webcam ended while the pipeline was loading: discard it and drop the
765
+ // dead camera rather than inheriting a frozen processed stream.
766
+ handle.cleanup();
767
+ this.cameraStream = null;
768
+ } else {
769
+ this.cameraBlur = handle;
770
+ this.cameraStream = this.cameraBlur.stream;
771
+ }
772
+ }
773
+
774
+ if (this.opts.mode === "camera" && !this.cameraStream) {
775
+ throw new Error(
776
+ "Camera disconnected before recording could start. Reconnect it and try again.",
777
+ );
778
+ }
779
+
723
780
  this.previewStream =
724
781
  this.opts.mode === "camera" ? this.cameraStream! : this.displayStream!;
725
782
 
@@ -1414,6 +1471,12 @@ export class RecorderEngine {
1414
1471
  return combined;
1415
1472
  }
1416
1473
 
1474
+ // Camera dropped before start (disconnected during setup/countdown):
1475
+ // record screen-only rather than compositing a dead stream.
1476
+ if (!this.cameraStream) {
1477
+ return this.buildDisplayRecordingStream();
1478
+ }
1479
+
1417
1480
  // Screen + camera: display capture does not reliably include our separate
1418
1481
  // DOM bubble once the user records another app/window, so the saved
1419
1482
  // recording must composite the camera feed before MediaRecorder sees it.
@@ -1705,14 +1768,23 @@ export class RecorderEngine {
1705
1768
  }
1706
1769
 
1707
1770
  private cleanupTracks(): void {
1771
+ // Clear before stopping tracks so the `ended` events our own stop() fires
1772
+ // aren't mistaken for a disconnect.
1773
+ this.cameraLive = false;
1708
1774
  this.audioMixSources = [];
1709
1775
  this.audioMixCtx?.close().catch(() => {});
1710
1776
  this.audioMixCtx = null;
1711
1777
  this.cameraComposite?.cleanup();
1712
1778
  this.cameraComposite = null;
1779
+ // Tear down the blur pipeline (segmenter, hidden video, processed capture)
1780
+ // before stopping streams. `rawCameraStream` holds the real hardware tracks
1781
+ // when blur is active; stop those so the camera indicator clears.
1782
+ this.cameraBlur?.cleanup();
1783
+ this.cameraBlur = null;
1713
1784
  for (const s of [
1714
1785
  this.displayStream,
1715
1786
  this.cameraStream,
1787
+ this.rawCameraStream,
1716
1788
  this.micStream,
1717
1789
  this.combinedStream,
1718
1790
  ]) {
@@ -1727,6 +1799,7 @@ export class RecorderEngine {
1727
1799
  }
1728
1800
  this.displayStream = null;
1729
1801
  this.cameraStream = null;
1802
+ this.rawCameraStream = null;
1730
1803
  this.micStream = null;
1731
1804
  this.combinedStream = null;
1732
1805
  this.previewStream = null;
@@ -1764,16 +1837,28 @@ export class RecorderEngine {
1764
1837
  * warn the user.
1765
1838
  */
1766
1839
  private onCameraTrackEnded() {
1767
- if (this.state !== "recording" && this.state !== "paused") return;
1840
+ if (!this.cameraLive) return;
1768
1841
  if (this.cameraDisconnectNotified) return;
1769
1842
  this.cameraDisconnectNotified = true;
1770
- for (const track of this.cameraStream?.getVideoTracks() ?? []) {
1843
+ this.cameraLive = false;
1844
+ // Tear down the blur pipeline so the composite's camera <video> sees its
1845
+ // (blurred) track end and self-hides, instead of freezing on a stale frame.
1846
+ if (this.cameraBlur) {
1847
+ this.cameraBlur.cleanup();
1848
+ this.cameraBlur = null;
1849
+ }
1850
+ for (const track of [
1851
+ ...(this.rawCameraStream?.getVideoTracks() ?? []),
1852
+ ...(this.cameraStream?.getVideoTracks() ?? []),
1853
+ ]) {
1771
1854
  try {
1772
1855
  track.stop();
1773
1856
  } catch {
1774
1857
  // ignore — the track has already ended.
1775
1858
  }
1776
1859
  }
1860
+ // Drop the on-page bubble to match the recorded output (screen-only now).
1861
+ this.opts.onCameraEnded?.();
1777
1862
  this.emitWarning(
1778
1863
  "Camera disconnected — recording continues without webcam.",
1779
1864
  );