@agent-native/core 0.70.2 → 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 (93) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -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/action.ts +21 -10
  7. package/corpus/core/src/agent/durable-background.ts +192 -0
  8. package/corpus/core/src/agent/production-agent.ts +330 -13
  9. package/corpus/core/src/agent/run-manager.ts +66 -3
  10. package/corpus/core/src/agent/run-store.ts +129 -23
  11. package/corpus/core/src/agent/types.ts +20 -0
  12. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  13. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  14. package/corpus/core/src/deploy/build.ts +96 -0
  15. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  16. package/corpus/core/src/mcp/build-server.ts +22 -13
  17. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  18. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  19. package/corpus/templates/analytics/AGENTS.md +8 -0
  20. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  21. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
  22. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  23. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  24. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
  25. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
  26. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  29. package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
  30. package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
  31. package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
  32. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
  33. package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
  34. package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
  35. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  36. package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
  37. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  38. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  39. package/corpus/templates/clips/desktop/src/lib/recorder.ts +17 -20
  40. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  41. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  42. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  43. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  44. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  45. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  46. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
  47. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
  48. package/corpus/templates/clips/shared/upload-limits.ts +5 -2
  49. package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
  50. package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
  51. package/dist/action.js +21 -10
  52. package/dist/action.js.map +1 -1
  53. package/dist/agent/durable-background.d.ts +60 -0
  54. package/dist/agent/durable-background.d.ts.map +1 -0
  55. package/dist/agent/durable-background.js +144 -0
  56. package/dist/agent/durable-background.js.map +1 -0
  57. package/dist/agent/production-agent.d.ts +20 -0
  58. package/dist/agent/production-agent.d.ts.map +1 -1
  59. package/dist/agent/production-agent.js +292 -14
  60. package/dist/agent/production-agent.js.map +1 -1
  61. package/dist/agent/run-manager.d.ts +48 -0
  62. package/dist/agent/run-manager.d.ts.map +1 -1
  63. package/dist/agent/run-manager.js +45 -3
  64. package/dist/agent/run-manager.js.map +1 -1
  65. package/dist/agent/run-store.d.ts +30 -1
  66. package/dist/agent/run-store.d.ts.map +1 -1
  67. package/dist/agent/run-store.js +124 -24
  68. package/dist/agent/run-store.js.map +1 -1
  69. package/dist/agent/types.d.ts +20 -0
  70. package/dist/agent/types.d.ts.map +1 -1
  71. package/dist/agent/types.js.map +1 -1
  72. package/dist/client/AgentPanel.d.ts.map +1 -1
  73. package/dist/client/AgentPanel.js +2 -2
  74. package/dist/client/AgentPanel.js.map +1 -1
  75. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  76. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  77. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  78. package/dist/deploy/build.d.ts +29 -0
  79. package/dist/deploy/build.d.ts.map +1 -1
  80. package/dist/deploy/build.js +85 -0
  81. package/dist/deploy/build.js.map +1 -1
  82. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  83. package/dist/deploy/workspace-deploy.js +108 -0
  84. package/dist/deploy/workspace-deploy.js.map +1 -1
  85. package/dist/mcp/build-server.d.ts.map +1 -1
  86. package/dist/mcp/build-server.js +23 -9
  87. package/dist/mcp/build-server.js.map +1 -1
  88. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  89. package/dist/server/agent-chat-plugin.js +68 -13
  90. package/dist/server/agent-chat-plugin.js.map +1 -1
  91. package/docs/content/multi-app-workspace.md +2 -2
  92. package/docs/design/durable-agent-runs.md +458 -4
  93. package/package.json +1 -1
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Dev loop with auto-reload for the unpacked extension.
3
+ *
4
+ * MV3 has no built-in hot reload for unpacked extensions, so this script:
5
+ * 1. runs `vite build --watch` (rebuilds dist/ on every source change), and
6
+ * 2. serves a tiny localhost stream that emits "reload" after each rebuild.
7
+ *
8
+ * The background service worker (in dev / unpacked only) holds that stream open
9
+ * and calls chrome.runtime.reload() when it sees "reload" — so saving a file
10
+ * rebuilds AND reloads the extension with no clicking in chrome://extensions.
11
+ * The open fetch also keeps the worker alive while you iterate.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import http from "node:http";
15
+ import { watch } from "node:fs";
16
+ import { dirname, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
+ const distDir = resolve(root, "dist");
21
+ const PORT = 8123;
22
+
23
+ const clients = new Set<http.ServerResponse>();
24
+
25
+ const server = http.createServer((req, res) => {
26
+ if (req.url === "/dev-reload-stream") {
27
+ res.writeHead(200, {
28
+ "Content-Type": "text/plain; charset=utf-8",
29
+ "Cache-Control": "no-store",
30
+ "Access-Control-Allow-Origin": "*",
31
+ Connection: "keep-alive",
32
+ });
33
+ res.write("connected\n");
34
+ clients.add(res);
35
+ req.on("close", () => clients.delete(res));
36
+ return;
37
+ }
38
+ res.writeHead(404);
39
+ res.end();
40
+ });
41
+
42
+ server.on("error", (err) => {
43
+ console.error(`[clips-dev] reload server error:`, err);
44
+ });
45
+
46
+ server.listen(PORT, () => {
47
+ console.log(`[clips-dev] reload server → http://localhost:${PORT}`);
48
+ console.log(
49
+ `[clips-dev] load ${distDir} as an unpacked extension; it auto-reloads on rebuild.`,
50
+ );
51
+ });
52
+
53
+ let debounce: ReturnType<typeof setTimeout> | undefined;
54
+ function notifyReload(): void {
55
+ clearTimeout(debounce);
56
+ debounce = setTimeout(() => {
57
+ if (clients.size === 0) return;
58
+ for (const res of clients) {
59
+ try {
60
+ res.write("reload\n");
61
+ } catch {
62
+ /* client went away */
63
+ }
64
+ }
65
+ console.log(
66
+ `[clips-dev] rebuild → reload sent to ${clients.size} client(s)`,
67
+ );
68
+ }, 250);
69
+ }
70
+
71
+ // Vite owns the actual build; we watch its output so the reload fires only after
72
+ // dist/ is fully rewritten.
73
+ const vite = spawn("pnpm", ["exec", "vite", "build", "--watch"], {
74
+ cwd: root,
75
+ stdio: "inherit",
76
+ });
77
+ vite.on("exit", (code) => {
78
+ console.log(`[clips-dev] vite exited (${code ?? 0})`);
79
+ server.close();
80
+ process.exit(code ?? 0);
81
+ });
82
+
83
+ let watching = false;
84
+ function startWatchingDist(): void {
85
+ if (watching) return;
86
+ try {
87
+ watch(distDir, { recursive: true }, () => notifyReload());
88
+ watching = true;
89
+ } catch {
90
+ // dist may not exist yet on the very first run — retry shortly.
91
+ setTimeout(startWatchingDist, 500);
92
+ }
93
+ }
94
+ startWatchingDist();
95
+
96
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
97
+ process.on(signal, () => {
98
+ vite.kill();
99
+ server.close();
100
+ process.exit(0);
101
+ });
102
+ }
@@ -210,7 +210,11 @@ type OverlayPart = "bubble" | "countdown" | "toolbar" | "saving";
210
210
  let overlayPhase: OverlayPhase = "idle";
211
211
  let overlayBaseElapsedMs = 0;
212
212
  let overlayBaseEpochMs = 0;
213
+ // Bubble during the countdown preview (any camera). During recording, the face
214
+ // is composited into the video by the offscreen for screen+camera, so we only
215
+ // show the on-page bubble for camera-only mode (recordingShowsBubble).
213
216
  let overlayShowsBubble = false;
217
+ let recordingShowsBubble = false;
214
218
  let countdownEndsAtMs = 0;
215
219
 
216
220
  function desiredParts(): OverlayPart[] {
@@ -221,7 +225,7 @@ function desiredParts(): OverlayPart[] {
221
225
  return overlayShowsBubble ? ["bubble", "countdown"] : ["countdown"];
222
226
  }
223
227
  if (overlayPhase === "recording" || overlayPhase === "paused") {
224
- return overlayShowsBubble ? ["bubble", "toolbar"] : ["toolbar"];
228
+ return recordingShowsBubble ? ["bubble", "toolbar"] : ["toolbar"];
225
229
  }
226
230
  if (overlayPhase === "saving") {
227
231
  return ["saving"];
@@ -286,6 +290,7 @@ function persistOverlay(): Promise<void> {
286
290
  baseElapsedMs: overlayBaseElapsedMs,
287
291
  baseEpochMs: overlayBaseEpochMs,
288
292
  showsBubble: overlayShowsBubble,
293
+ recordingShowsBubble,
289
294
  countdownEndsAtMs,
290
295
  },
291
296
  }).catch(() => undefined);
@@ -306,6 +311,7 @@ async function restoreRuntimeState(): Promise<void> {
306
311
  baseElapsedMs?: number;
307
312
  baseEpochMs?: number;
308
313
  showsBubble?: boolean;
314
+ recordingShowsBubble?: boolean;
309
315
  countdownEndsAtMs?: number;
310
316
  }
311
317
  | undefined;
@@ -316,6 +322,7 @@ async function restoreRuntimeState(): Promise<void> {
316
322
  overlayBaseEpochMs =
317
323
  typeof rt.baseEpochMs === "number" ? rt.baseEpochMs : 0;
318
324
  overlayShowsBubble = Boolean(rt.showsBubble);
325
+ recordingShowsBubble = Boolean(rt.recordingShowsBubble);
319
326
  countdownEndsAtMs =
320
327
  typeof rt.countdownEndsAtMs === "number" ? rt.countdownEndsAtMs : 0;
321
328
  }
@@ -413,6 +420,7 @@ function resetOverlay(): void {
413
420
  overlayBaseElapsedMs = 0;
414
421
  overlayBaseEpochMs = 0;
415
422
  overlayShowsBubble = false;
423
+ recordingShowsBubble = false;
416
424
  clearCountdownTimer();
417
425
  setRecordingFlag(false);
418
426
  // Bring back the toolbar popup now that we're idle again.
@@ -420,6 +428,41 @@ function resetOverlay(): void {
420
428
  void persistOverlay();
421
429
  }
422
430
 
431
+ // ----- Pre-record camera preview --------------------------------------------
432
+ // While the popup is open and idle, show the live face bubble on the active tab
433
+ // (like the desktop app's pre-record bubble). The popup holds a runtime port
434
+ // open; its disconnect (popup closed) tears the preview down.
435
+ let previewTabId: number | null = null;
436
+
437
+ async function startPreview(wantsCamera: boolean): Promise<void> {
438
+ await ensureRestored();
439
+ // Never show a preview over an active recording (the recording overlay owns
440
+ // the tab then). If the camera is off, make sure any preview is removed.
441
+ if (overlayPhase !== "idle" || !wantsCamera) {
442
+ await stopPreview();
443
+ return;
444
+ }
445
+ const tab = await queryActiveTab();
446
+ if (!tab || typeof tab.id !== "number") return;
447
+ if (previewTabId !== null && previewTabId !== tab.id) await stopPreview();
448
+ previewTabId = tab.id;
449
+ await ensureContentScript(tab.id);
450
+ // Injection / send fail silently on unsupported pages (chrome://, etc.).
451
+ await sendTabMessage(tab.id, {
452
+ type: "CLIPS_OVERLAY_MOUNT",
453
+ parts: ["bubble"],
454
+ });
455
+ }
456
+
457
+ async function stopPreview(): Promise<void> {
458
+ const tabId = previewTabId;
459
+ previewTabId = null;
460
+ if (tabId === null) return;
461
+ // Don't tear down a recording overlay that took over this tab.
462
+ if (overlayPhase !== "idle") return;
463
+ await sendTabMessage(tabId, { type: "CLIPS_OVERLAY_UNMOUNT" });
464
+ }
465
+
423
466
  // Mirrored into chrome.storage.local so content scripts can cheaply tell whether
424
467
  // a recording is in progress without waking the service worker on every page
425
468
  // load. Content scripts can read storage.local by default; storage.session
@@ -875,6 +918,7 @@ async function armRecording(args: {
875
918
  settings.captureSurface === "camera" ? "camera" : "screen";
876
919
  const surface: "browser" | "window" | "monitor" =
877
920
  settings.captureSurface === "camera" ? "browser" : settings.captureSurface;
921
+ console.log("[clips-bg] arm: start", { mode, surface, tabId: tab.id });
878
922
 
879
923
  // Pre-warmed on popup open, so this is effectively instant and keeps the
880
924
  // getDisplayMedia() call close to the user's click.
@@ -882,15 +926,51 @@ async function armRecording(args: {
882
926
 
883
927
  // 1) Native "Choose what to share" picker. Do this before any network round
884
928
  // trip so the picker stays tied to the user gesture. Throws if cancelled.
885
- await sendOffscreenMessage<{ ok: boolean; width: number; height: number }>({
929
+ const acq = await sendOffscreenMessage<{
930
+ ok: boolean;
931
+ width: number;
932
+ height: number;
933
+ }>({
886
934
  type: "CLIPS_OFFSCREEN_ACQUIRE",
887
935
  sessionId,
888
936
  mode,
889
937
  surface,
890
938
  includeMicrophone: settings.includeMicrophone,
939
+ includeCamera: settings.includeCamera,
891
940
  });
941
+ console.log("[clips-bg] arm: acquired stream", acq);
942
+
943
+ // 2) Show the countdown overlay IMMEDIATELY — before the network round-trip —
944
+ // so the 3-2-1 runs full-length and the dim/blur clears exactly when the
945
+ // recorder starts. The on-page bubble shows during countdown AND recording
946
+ // (the face is captured in the display; we do not composite).
947
+ const cameraInvolved = mode === "camera" || settings.includeCamera;
948
+ overlayPhase = "countdown";
949
+ overlayBaseElapsedMs = 0;
950
+ overlayBaseEpochMs = nowMs();
951
+ overlayShowsBubble = cameraInvolved;
952
+ recordingShowsBubble = cameraInvolved;
953
+ countdownEndsAtMs = nowMs() + COUNTDOWN_SECONDS * 1000;
954
+ // The recording overlay now owns this tab's bubble — hand off from any preview
955
+ // so the popup-close disconnect won't tear down the live recording overlay.
956
+ previewTabId = null;
957
+ setRecordingFlag(true);
958
+ // Clicking the icon now stops & saves immediately instead of opening the popup.
959
+ setActionPopup("");
960
+ await mountOverlayOnTab(tab.id as number);
961
+ broadcastOverlayState();
892
962
 
893
- // 2) Create the recording row now that we know the user picked a source.
963
+ const abortArming = async (): Promise<void> => {
964
+ await sendOffscreenMessage({
965
+ type: "CLIPS_OFFSCREEN_CANCEL",
966
+ sessionId,
967
+ }).catch(() => undefined);
968
+ resetOverlay();
969
+ await broadcastUnmount();
970
+ await chrome.action.setBadgeText({ text: "" });
971
+ };
972
+
973
+ // 3) Create the recording row (happens during the countdown).
894
974
  type CreatedRecording = {
895
975
  id?: string;
896
976
  uploadChunkUrl?: string;
@@ -903,23 +983,17 @@ async function armRecording(args: {
903
983
  titleSource: tab.title ? "context" : "default",
904
984
  sourceAppName: "Chrome",
905
985
  sourceWindowTitle: tab.title ?? null,
906
- hasCamera: settings.captureSurface === "camera" || settings.includeCamera,
986
+ hasCamera: cameraInvolved,
907
987
  hasAudio:
908
988
  settings.includeMicrophone || settings.captureSurface !== "camera",
909
989
  visibility: "public",
910
990
  });
911
991
  } catch (err) {
912
- await sendOffscreenMessage({
913
- type: "CLIPS_OFFSCREEN_CANCEL",
914
- sessionId,
915
- }).catch(() => undefined);
992
+ await abortArming();
916
993
  throw err;
917
994
  }
918
995
  if (!created.id || !created.uploadChunkUrl) {
919
- await sendOffscreenMessage({
920
- type: "CLIPS_OFFSCREEN_CANCEL",
921
- sessionId,
922
- }).catch(() => undefined);
996
+ await abortArming();
923
997
  throw new Error("Clips did not create a recording target.");
924
998
  }
925
999
 
@@ -946,35 +1020,24 @@ async function armRecording(args: {
946
1020
  error: null,
947
1021
  };
948
1022
 
949
- // 3) Enter the countdown phase and show the overlay on the launch tab. The
950
- // camera bubble shows whenever a camera is involved: as the picture-in-
951
- // picture for screen+camera, or as a live self-preview for camera-only.
952
- overlayPhase = "countdown";
953
- overlayBaseElapsedMs = 0;
954
- overlayBaseEpochMs = nowMs();
955
- overlayShowsBubble = mode === "camera" || settings.includeCamera;
956
- countdownEndsAtMs = nowMs() + COUNTDOWN_SECONDS * 1000;
957
- setRecordingFlag(true);
958
- // Clicking the icon now stops & saves immediately instead of opening the popup.
959
- setActionPopup("");
960
-
961
- // Hand the recorder its targets plus the pre-roll delay. The offscreen document
962
- // (a reliable context, unlike the suspendable worker) owns the countdown timer
963
- // and reports "recording" back when the recorder actually starts — that status
964
- // flip (markRecordingStarted) advances our phase, so recording begins even on
965
- // pages where no overlay can be injected (chrome://, the New Tab page, etc.).
1023
+ // 4) Start the recorder when the (already-running) countdown ends. The
1024
+ // offscreen owns the pre-roll timer (a reliable context, unlike the
1025
+ // suspendable worker) and reports "recording" back when it actually starts.
966
1026
  const authToken = (await readAuthSession(settings))?.token;
1027
+ console.log("[clips-bg] arm: created row", created.id, "auth?", !!authToken);
1028
+ const startDelayMs = Math.max(0, countdownEndsAtMs - nowMs());
967
1029
  try {
968
1030
  await sendOffscreenMessage({
969
1031
  type: "CLIPS_OFFSCREEN_BEGIN",
970
1032
  sessionId,
971
1033
  recordingId: created.id,
972
1034
  uploadUrl,
973
- hasCamera: mode === "camera" || settings.includeCamera,
974
- startDelayMs: COUNTDOWN_SECONDS * 1000,
1035
+ hasCamera: cameraInvolved,
1036
+ startDelayMs,
975
1037
  authToken,
976
1038
  });
977
1039
  } catch (err) {
1040
+ console.error("[clips-bg] arm: BEGIN failed", err);
978
1041
  await cancelRecording();
979
1042
  throw err;
980
1043
  }
@@ -985,10 +1048,10 @@ async function armRecording(args: {
985
1048
  await attachSession(session);
986
1049
  }
987
1050
 
988
- await mountOverlayOnTab(tab.id as number);
989
1051
  broadcastOverlayState();
990
1052
  await chrome.action.setBadgeBackgroundColor({ color: "#e11d48" });
991
1053
  await chrome.action.setBadgeText({ text: "REC" });
1054
+ console.log("[clips-bg] arm: done — countdown, parts:", desiredParts());
992
1055
 
993
1056
  return { ok: true, sessionId, recordingId: created.id, native: true };
994
1057
  }
@@ -998,6 +1061,7 @@ async function armRecording(args: {
998
1061
  // on-page timer. Idempotent.
999
1062
  async function markRecordingStarted() {
1000
1063
  if (overlayPhase !== "countdown") return;
1064
+ console.log("[clips-bg] recorder started → phase=recording");
1001
1065
  clearCountdownTimer();
1002
1066
  overlayPhase = "recording";
1003
1067
  overlayBaseElapsedMs = 0;
@@ -1178,6 +1242,7 @@ async function finishSaving(
1178
1242
 
1179
1243
  async function stopRecording() {
1180
1244
  const recording = activeNativeRecording;
1245
+ console.log("[clips-bg] stopRecording — active:", !!recording);
1181
1246
  if (!recording) return { ok: false, error: "No active Clips recording." };
1182
1247
  recording.status = "stopping";
1183
1248
  // Keep an overlay up: swap the recording controls/bubble for a "Saving…" card
@@ -1821,6 +1886,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1821
1886
  try {
1822
1887
  response = await dispatchRuntimeMessage(message);
1823
1888
  } catch (err) {
1889
+ console.error(
1890
+ "[clips-bg] message failed:",
1891
+ (message as { type?: unknown })?.type,
1892
+ err,
1893
+ );
1824
1894
  response = {
1825
1895
  ok: false,
1826
1896
  error: err instanceof Error ? err.message : "Could not use Clips.",
@@ -1841,6 +1911,13 @@ async function dispatchRuntimeMessage(message: unknown): Promise<unknown> {
1841
1911
  // Status updates streamed from the offscreen recorder.
1842
1912
  if (type === "CLIPS_NATIVE_STATUS") {
1843
1913
  const status = message as OffscreenStatusMessage;
1914
+ console.log(
1915
+ "[clips-bg] status:",
1916
+ status.status,
1917
+ status.error ?? "",
1918
+ "phase:",
1919
+ overlayPhase,
1920
+ );
1844
1921
  if (
1845
1922
  activeNativeRecording &&
1846
1923
  activeNativeRecording.sessionId === status.sessionId
@@ -1909,6 +1986,11 @@ async function dispatchRuntimeMessage(message: unknown): Promise<unknown> {
1909
1986
  return { ok: true };
1910
1987
  }
1911
1988
 
1989
+ if (type === "CLIPS_PREVIEW") {
1990
+ await startPreview(Boolean((message as { camera?: unknown }).camera));
1991
+ return { ok: true };
1992
+ }
1993
+
1912
1994
  switch (type) {
1913
1995
  case "CLIPS_POPUP_START":
1914
1996
  return handlePopupStart(message as PopupStartMessage);
@@ -1961,6 +2043,12 @@ chrome.action.onClicked.addListener(() => {
1961
2043
  // Await restore — the click may have woken the worker, leaving the module
1962
2044
  // globals empty until this resolves. Without it, Stop silently does nothing.
1963
2045
  await ensureRestored();
2046
+ console.log(
2047
+ "[clips-bg] icon clicked — phase:",
2048
+ overlayPhase,
2049
+ "active:",
2050
+ !!activeNativeRecording,
2051
+ );
1964
2052
  if (!activeNativeRecording) return;
1965
2053
  if (overlayPhase === "countdown") await cancelRecording();
1966
2054
  else if (overlayPhase === "recording" || overlayPhase === "paused") {
@@ -1969,6 +2057,16 @@ chrome.action.onClicked.addListener(() => {
1969
2057
  })();
1970
2058
  });
1971
2059
 
2060
+ // The popup opens a port for as long as it is visible; its disconnect (popup
2061
+ // closed) is our reliable signal to remove the pre-record preview bubble.
2062
+ chrome.runtime.onConnect.addListener((port) => {
2063
+ if (port.name !== "clips-preview") return;
2064
+ port.onDisconnect.addListener(() => {
2065
+ void chrome.runtime.lastError;
2066
+ void stopPreview();
2067
+ });
2068
+ });
2069
+
1972
2070
  chrome.runtime.onMessageExternal.addListener(
1973
2071
  (message, sender, sendResponse) => {
1974
2072
  void handleExternalMessage(message as ExternalMessage, sender)
@@ -2027,3 +2125,37 @@ chrome.debugger.onDetach.addListener((source) => {
2027
2125
  if (session) session.attached = false;
2028
2126
  tabToSession.delete(tabId);
2029
2127
  });
2128
+
2129
+ // ---- Dev auto-reload (unpacked installs only) ------------------------------
2130
+ // `pnpm dev:hot` runs a localhost server that streams "reload" after each
2131
+ // rebuild; we hold the stream open and reload the extension when it fires, so
2132
+ // edits land without touching chrome://extensions. The open fetch also keeps
2133
+ // this worker alive while iterating. No-ops for packed/Web Store installs
2134
+ // (they have an update_url) and quietly retries when no dev server is running.
2135
+ function startDevHotReload(): void {
2136
+ if ("update_url" in chrome.runtime.getManifest()) return;
2137
+ const url = "http://localhost:8123/dev-reload-stream";
2138
+ const connect = (): void => {
2139
+ fetch(url, { cache: "no-store" })
2140
+ .then(async (res) => {
2141
+ const reader = res.body?.getReader();
2142
+ if (!reader) {
2143
+ setTimeout(connect, 2500);
2144
+ return;
2145
+ }
2146
+ const decoder = new TextDecoder();
2147
+ for (;;) {
2148
+ const { value, done } = await reader.read();
2149
+ if (done) break;
2150
+ if (decoder.decode(value, { stream: true }).includes("reload")) {
2151
+ chrome.runtime.reload();
2152
+ return;
2153
+ }
2154
+ }
2155
+ setTimeout(connect, 1000);
2156
+ })
2157
+ .catch(() => setTimeout(connect, 2500));
2158
+ };
2159
+ connect();
2160
+ }
2161
+ startDevHotReload();