@agent-native/core 0.70.3 → 0.72.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 (221) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +51 -0
  3. package/corpus/core/docs/content/actions.md +23 -0
  4. package/corpus/core/docs/content/audit-log.md +111 -0
  5. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  6. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  7. package/corpus/core/package.json +2 -1
  8. package/corpus/core/src/action.ts +80 -1
  9. package/corpus/core/src/agent/durable-background.ts +192 -0
  10. package/corpus/core/src/agent/production-agent.ts +357 -13
  11. package/corpus/core/src/agent/run-manager.ts +66 -3
  12. package/corpus/core/src/agent/run-store.ts +143 -23
  13. package/corpus/core/src/agent/types.ts +20 -0
  14. package/corpus/core/src/application-state/store.ts +5 -0
  15. package/corpus/core/src/audit/actions/get-audit-event.ts +23 -0
  16. package/corpus/core/src/audit/actions/list-audit-events.ts +65 -0
  17. package/corpus/core/src/audit/cleanup-job.ts +100 -0
  18. package/corpus/core/src/audit/config.ts +91 -0
  19. package/corpus/core/src/audit/index.ts +43 -0
  20. package/corpus/core/src/audit/record.ts +143 -0
  21. package/corpus/core/src/audit/redact.ts +109 -0
  22. package/corpus/core/src/audit/store.ts +244 -0
  23. package/corpus/core/src/audit/types.ts +125 -0
  24. package/corpus/core/src/chat-threads/store.ts +10 -0
  25. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  26. package/corpus/core/src/client/AssistantChat.tsx +6 -0
  27. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  28. package/corpus/core/src/client/chat/repo-helpers.ts +38 -0
  29. package/corpus/core/src/db/client.ts +4 -0
  30. package/corpus/core/src/db/widen-columns.ts +75 -0
  31. package/corpus/core/src/deploy/build.ts +96 -0
  32. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  33. package/corpus/core/src/mcp/build-server.ts +23 -13
  34. package/corpus/core/src/oauth-tokens/store.ts +6 -0
  35. package/corpus/core/src/provider-api/custom-registry.ts +8 -0
  36. package/corpus/core/src/resources/store.ts +10 -0
  37. package/corpus/core/src/scripts/runner.ts +6 -3
  38. package/corpus/core/src/server/action-discovery.ts +6 -0
  39. package/corpus/core/src/server/action-routes.ts +1 -0
  40. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  41. package/corpus/core/src/server/auth.ts +5 -0
  42. package/corpus/core/src/server/core-routes-plugin.ts +14 -0
  43. package/corpus/core/src/server/security-headers.ts +9 -6
  44. package/corpus/core/src/settings/store.ts +6 -0
  45. package/corpus/core/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  46. package/corpus/core/src/usage/store.ts +6 -0
  47. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  48. package/corpus/templates/analytics/AGENTS.md +8 -0
  49. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  50. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  51. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  52. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +60 -6
  53. package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +6 -0
  54. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +20 -43
  55. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +5 -1
  56. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +3 -51
  57. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  58. package/corpus/templates/clips/changelog/2026-06-23-public-clips-now-play-inline-in-slack-connect-a-workspac.md +6 -0
  59. package/corpus/templates/clips/chrome-extension/PERMISSIONS.md +72 -0
  60. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -10
  61. package/corpus/templates/clips/chrome-extension/src/background.ts +163 -32
  62. package/corpus/templates/clips/chrome-extension/src/content-script.ts +301 -24
  63. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +245 -67
  64. package/corpus/templates/clips/chrome-extension/src/overlay.css +77 -43
  65. package/corpus/templates/clips/chrome-extension/src/overlay.html +7 -1
  66. package/corpus/templates/clips/chrome-extension/src/overlay.ts +102 -38
  67. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  68. package/corpus/templates/clips/chrome-extension/src/popup.ts +275 -4
  69. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  70. package/corpus/templates/clips/chrome-extension/vite.config.ts +5 -0
  71. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  72. package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
  73. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  74. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  75. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  76. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  77. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  78. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  79. package/corpus/templates/clips/server/lib/media-permissions.ts +5 -1
  80. package/corpus/templates/clips/shared/recording-audio.ts +62 -0
  81. package/corpus/templates/clips/shared/recording-core.ts +94 -0
  82. package/dist/action.d.ts +31 -0
  83. package/dist/action.d.ts.map +1 -1
  84. package/dist/action.js +45 -1
  85. package/dist/action.js.map +1 -1
  86. package/dist/agent/context-xray/schema.d.ts +1 -1
  87. package/dist/agent/durable-background.d.ts +60 -0
  88. package/dist/agent/durable-background.d.ts.map +1 -0
  89. package/dist/agent/durable-background.js +144 -0
  90. package/dist/agent/durable-background.js.map +1 -0
  91. package/dist/agent/observational-memory/schema.d.ts +1 -1
  92. package/dist/agent/production-agent.d.ts +20 -0
  93. package/dist/agent/production-agent.d.ts.map +1 -1
  94. package/dist/agent/production-agent.js +320 -14
  95. package/dist/agent/production-agent.js.map +1 -1
  96. package/dist/agent/run-manager.d.ts +48 -0
  97. package/dist/agent/run-manager.d.ts.map +1 -1
  98. package/dist/agent/run-manager.js +45 -3
  99. package/dist/agent/run-manager.js.map +1 -1
  100. package/dist/agent/run-store.d.ts +30 -1
  101. package/dist/agent/run-store.d.ts.map +1 -1
  102. package/dist/agent/run-store.js +138 -24
  103. package/dist/agent/run-store.js.map +1 -1
  104. package/dist/agent/types.d.ts +20 -0
  105. package/dist/agent/types.d.ts.map +1 -1
  106. package/dist/agent/types.js.map +1 -1
  107. package/dist/application-state/store.d.ts.map +1 -1
  108. package/dist/application-state/store.js +5 -0
  109. package/dist/application-state/store.js.map +1 -1
  110. package/dist/audit/actions/get-audit-event.d.ts +11 -0
  111. package/dist/audit/actions/get-audit-event.d.ts.map +1 -0
  112. package/dist/audit/actions/get-audit-event.js +22 -0
  113. package/dist/audit/actions/get-audit-event.js.map +1 -0
  114. package/dist/audit/actions/list-audit-events.d.ts +22 -0
  115. package/dist/audit/actions/list-audit-events.d.ts.map +1 -0
  116. package/dist/audit/actions/list-audit-events.js +61 -0
  117. package/dist/audit/actions/list-audit-events.js.map +1 -0
  118. package/dist/audit/cleanup-job.d.ts +12 -0
  119. package/dist/audit/cleanup-job.d.ts.map +1 -0
  120. package/dist/audit/cleanup-job.js +93 -0
  121. package/dist/audit/cleanup-job.js.map +1 -0
  122. package/dist/audit/config.d.ts +30 -0
  123. package/dist/audit/config.d.ts.map +1 -0
  124. package/dist/audit/config.js +65 -0
  125. package/dist/audit/config.js.map +1 -0
  126. package/dist/audit/index.d.ts +13 -0
  127. package/dist/audit/index.d.ts.map +1 -0
  128. package/dist/audit/index.js +6 -0
  129. package/dist/audit/index.js.map +1 -0
  130. package/dist/audit/record.d.ts +24 -0
  131. package/dist/audit/record.d.ts.map +1 -0
  132. package/dist/audit/record.js +106 -0
  133. package/dist/audit/record.js.map +1 -0
  134. package/dist/audit/redact.d.ts +28 -0
  135. package/dist/audit/redact.d.ts.map +1 -0
  136. package/dist/audit/redact.js +109 -0
  137. package/dist/audit/redact.js.map +1 -0
  138. package/dist/audit/store.d.ts +14 -0
  139. package/dist/audit/store.d.ts.map +1 -0
  140. package/dist/audit/store.js +219 -0
  141. package/dist/audit/store.js.map +1 -0
  142. package/dist/audit/types.d.ts +114 -0
  143. package/dist/audit/types.d.ts.map +1 -0
  144. package/dist/audit/types.js +15 -0
  145. package/dist/audit/types.js.map +1 -0
  146. package/dist/chat-threads/store.d.ts.map +1 -1
  147. package/dist/chat-threads/store.js +10 -0
  148. package/dist/chat-threads/store.js.map +1 -1
  149. package/dist/client/AgentPanel.d.ts.map +1 -1
  150. package/dist/client/AgentPanel.js +2 -2
  151. package/dist/client/AgentPanel.js.map +1 -1
  152. package/dist/client/AssistantChat.d.ts.map +1 -1
  153. package/dist/client/AssistantChat.js +6 -1
  154. package/dist/client/AssistantChat.js.map +1 -1
  155. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  156. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  157. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  158. package/dist/client/chat/repo-helpers.d.ts +16 -0
  159. package/dist/client/chat/repo-helpers.d.ts.map +1 -1
  160. package/dist/client/chat/repo-helpers.js +40 -0
  161. package/dist/client/chat/repo-helpers.js.map +1 -1
  162. package/dist/db/client.d.ts.map +1 -1
  163. package/dist/db/client.js +3 -0
  164. package/dist/db/client.js.map +1 -1
  165. package/dist/db/widen-columns.d.ts +39 -0
  166. package/dist/db/widen-columns.d.ts.map +1 -0
  167. package/dist/db/widen-columns.js +73 -0
  168. package/dist/db/widen-columns.js.map +1 -0
  169. package/dist/deploy/build.d.ts +29 -0
  170. package/dist/deploy/build.d.ts.map +1 -1
  171. package/dist/deploy/build.js +85 -0
  172. package/dist/deploy/build.js.map +1 -1
  173. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  174. package/dist/deploy/workspace-deploy.js +108 -0
  175. package/dist/deploy/workspace-deploy.js.map +1 -1
  176. package/dist/mcp/build-server.d.ts.map +1 -1
  177. package/dist/mcp/build-server.js +24 -9
  178. package/dist/mcp/build-server.js.map +1 -1
  179. package/dist/oauth-tokens/store.d.ts.map +1 -1
  180. package/dist/oauth-tokens/store.js +6 -0
  181. package/dist/oauth-tokens/store.js.map +1 -1
  182. package/dist/provider-api/custom-registry.d.ts.map +1 -1
  183. package/dist/provider-api/custom-registry.js +8 -0
  184. package/dist/provider-api/custom-registry.js.map +1 -1
  185. package/dist/resources/store.d.ts.map +1 -1
  186. package/dist/resources/store.js +9 -0
  187. package/dist/resources/store.js.map +1 -1
  188. package/dist/scripts/runner.js +4 -3
  189. package/dist/scripts/runner.js.map +1 -1
  190. package/dist/server/action-discovery.d.ts.map +1 -1
  191. package/dist/server/action-discovery.js +6 -0
  192. package/dist/server/action-discovery.js.map +1 -1
  193. package/dist/server/action-routes.d.ts.map +1 -1
  194. package/dist/server/action-routes.js +1 -0
  195. package/dist/server/action-routes.js.map +1 -1
  196. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  197. package/dist/server/agent-chat-plugin.js +68 -13
  198. package/dist/server/agent-chat-plugin.js.map +1 -1
  199. package/dist/server/auth.d.ts.map +1 -1
  200. package/dist/server/auth.js +5 -0
  201. package/dist/server/auth.js.map +1 -1
  202. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  203. package/dist/server/core-routes-plugin.js +13 -0
  204. package/dist/server/core-routes-plugin.js.map +1 -1
  205. package/dist/server/security-headers.d.ts +8 -5
  206. package/dist/server/security-headers.d.ts.map +1 -1
  207. package/dist/server/security-headers.js +9 -6
  208. package/dist/server/security-headers.js.map +1 -1
  209. package/dist/settings/store.d.ts.map +1 -1
  210. package/dist/settings/store.js +6 -0
  211. package/dist/settings/store.js.map +1 -1
  212. package/dist/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  213. package/dist/usage/store.d.ts.map +1 -1
  214. package/dist/usage/store.js +5 -0
  215. package/dist/usage/store.js.map +1 -1
  216. package/docs/content/actions.md +23 -0
  217. package/docs/content/audit-log.md +111 -0
  218. package/docs/content/multi-app-workspace.md +2 -2
  219. package/docs/design/durable-agent-runs.md +458 -4
  220. package/package.json +2 -1
  221. package/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
@@ -19,6 +19,15 @@ import {
19
19
  createCameraCompositeStream,
20
20
  type CameraCompositeHandle,
21
21
  } from "@/lib/camera-composite";
22
+ import {
23
+ chunkUploadUrl,
24
+ pickMimeType,
25
+ pickMimeTypeCandidates,
26
+ } from "@shared/recording-core";
27
+
28
+ // Re-exported for existing callers; the canonical impls live in
29
+ // @shared/recording-core and are shared with the Chrome extension recorder.
30
+ export { pickMimeType, pickMimeTypeCandidates };
22
31
 
23
32
  export type RecordingMode = "screen" | "camera" | "screen+camera";
24
33
  export type DisplaySurface = "monitor" | "window" | "browser";
@@ -281,34 +290,6 @@ function isScreenPickerDismissal(err: unknown): boolean {
281
290
  return false;
282
291
  }
283
292
 
284
- /** Pick a MediaRecorder mimeType the current browser actually supports. */
285
- export function pickMimeTypeCandidates(): string[] {
286
- // Chrome can report MP4 support but still reject the encoder configuration
287
- // for some display-capture streams. Prefer the WebM combinations that are
288
- // broadly supported by MediaRecorder, then fall back to MP4/Safari.
289
- return [
290
- "video/webm;codecs=vp8,opus",
291
- "video/webm;codecs=vp9,opus",
292
- "video/webm;codecs=vp8",
293
- "video/webm;codecs=vp9",
294
- "video/webm",
295
- "video/mp4;codecs=avc1",
296
- "video/mp4",
297
- ];
298
- }
299
-
300
- export function pickMimeType(): string {
301
- if (typeof MediaRecorder === "undefined") return "video/webm";
302
- for (const type of pickMimeTypeCandidates()) {
303
- try {
304
- if (MediaRecorder.isTypeSupported(type)) return type;
305
- } catch {
306
- // continue
307
- }
308
- }
309
- return "";
310
- }
311
-
312
293
  function canUseTimeslicedRecorderChunks(mimeType: string): boolean {
313
294
  // WebM chunks emitted by MediaRecorder are safe to concatenate locally before
314
295
  // compression/upload. Browser MP4 chunks are not reliably self-contained; in
@@ -1589,21 +1570,17 @@ export class RecorderEngine {
1589
1570
  signal?: AbortSignal;
1590
1571
  } = {},
1591
1572
  ): Promise<Record<string, unknown> | undefined> {
1592
- const params = new URLSearchParams();
1593
- params.set("index", String(index));
1594
- if (extra.total !== undefined) params.set("total", String(extra.total));
1595
- params.set("isFinal", extra.isFinal ? "1" : "0");
1596
- if (extra.mimeType) params.set("mimeType", extra.mimeType);
1597
- if (extra.durationMs !== undefined)
1598
- params.set("durationMs", String(Math.round(extra.durationMs)));
1599
- if (extra.width !== undefined) params.set("width", String(extra.width));
1600
- if (extra.height !== undefined) params.set("height", String(extra.height));
1601
- if (extra.hasAudio !== undefined)
1602
- params.set("hasAudio", extra.hasAudio ? "1" : "0");
1603
- if (extra.hasCamera !== undefined)
1604
- params.set("hasCamera", extra.hasCamera ? "1" : "0");
1605
-
1606
- const url = `${this.opts.uploadUrl}?${params.toString()}`;
1573
+ const url = chunkUploadUrl(this.opts.uploadUrl, {
1574
+ index,
1575
+ total: extra.total,
1576
+ isFinal: extra.isFinal,
1577
+ mimeType: extra.mimeType,
1578
+ durationMs: extra.durationMs,
1579
+ width: extra.width,
1580
+ height: extra.height,
1581
+ hasAudio: extra.hasAudio,
1582
+ hasCamera: extra.hasCamera,
1583
+ });
1607
1584
 
1608
1585
  const body = await blob.arrayBuffer();
1609
1586
  let res: Response | null = null;
@@ -25,6 +25,10 @@ export interface RecordingToolbarProps {
25
25
 
26
26
  const TOOLBAR_WIDTH = 276;
27
27
  const TOOLBAR_HEIGHT = 56;
28
+ // Drop the toolbar just below the centered "Recording your screen…" status
29
+ // text (which sits at the viewport's vertical center) so the controls don't
30
+ // overlap it.
31
+ const TOOLBAR_TOP_OFFSET = 48;
28
32
 
29
33
  function formatElapsed(ms: number): string {
30
34
  const totalSeconds = Math.max(0, Math.floor(ms / 1000));
@@ -47,7 +51,7 @@ export function RecordingToolbar({
47
51
  ? { left: 16, top: 16, corner: "tl" }
48
52
  : {
49
53
  left: Math.max(16, (window.innerWidth - TOOLBAR_WIDTH) / 2),
50
- top: Math.max(16, (window.innerHeight - TOOLBAR_HEIGHT) / 2),
54
+ top: Math.max(16, window.innerHeight / 2 + TOOLBAR_TOP_OFFSET),
51
55
  corner: "tl",
52
56
  },
53
57
  );
@@ -1,3 +1,5 @@
1
+ import { scheduleReadyChime } from "@shared/recording-audio";
2
+
1
3
  export interface CountdownAudioCue {
2
4
  play(): Promise<void>;
3
5
  cleanup(): void;
@@ -8,56 +10,6 @@ const noopCountdownAudioCue: CountdownAudioCue = {
8
10
  cleanup() {},
9
11
  };
10
12
 
11
- /**
12
- * A soft, modern "ready" chime: a rising two-note (D5 → A5) with a faint high
13
- * shimmer. Each voice has a fast attack and a smooth exponential tail so it
14
- * reads as a gentle confirmation rather than a harsh beep. Kept under ~380ms so
15
- * it lands cleanly just before capture.
16
- */
17
- function scheduleCountdownTone(ctx: AudioContext): Promise<void> {
18
- return new Promise<void>((resolve) => {
19
- const t0 = ctx.currentTime + 0.005;
20
- const voices = [
21
- { freq: 587.33, at: 0.0, dur: 0.22, peak: 0.06 }, // D5
22
- { freq: 880.0, at: 0.06, dur: 0.26, peak: 0.075 }, // A5
23
- { freq: 1760.0, at: 0.065, dur: 0.14, peak: 0.02 }, // A6 shimmer
24
- ];
25
-
26
- let lastStop = t0;
27
- for (const voice of voices) {
28
- const startAt = t0 + voice.at;
29
- const stopAt = startAt + voice.dur;
30
- lastStop = Math.max(lastStop, stopAt);
31
-
32
- const oscillator = ctx.createOscillator();
33
- oscillator.type = "sine";
34
- oscillator.frequency.setValueAtTime(voice.freq, startAt);
35
-
36
- const gain = ctx.createGain();
37
- gain.gain.setValueAtTime(0.0001, startAt);
38
- gain.gain.exponentialRampToValueAtTime(voice.peak, startAt + 0.012);
39
- gain.gain.exponentialRampToValueAtTime(0.0001, stopAt);
40
-
41
- oscillator.connect(gain);
42
- gain.connect(ctx.destination);
43
-
44
- oscillator.start(startAt);
45
- oscillator.stop(stopAt + 0.02);
46
- }
47
-
48
- let resolved = false;
49
- const finish = () => {
50
- if (resolved) return;
51
- resolved = true;
52
- resolve();
53
- };
54
- window.setTimeout(
55
- finish,
56
- Math.ceil((lastStop - ctx.currentTime) * 1000) + 60,
57
- );
58
- });
59
- }
60
-
61
13
  export function createCountdownAudioCue(): CountdownAudioCue {
62
14
  try {
63
15
  const AudioCtx =
@@ -87,7 +39,7 @@ export function createCountdownAudioCue(): CountdownAudioCue {
87
39
  try {
88
40
  if (ctx.state !== "running") await ctx.resume();
89
41
  if (closed) return;
90
- await scheduleCountdownTone(ctx);
42
+ await scheduleReadyChime(ctx);
91
43
  } catch (err) {
92
44
  console.warn("[recorder] countdown cue unavailable:", err);
93
45
  cleanup();
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-23
4
+ ---
5
+
6
+ Dragging the desktop camera bubble now glides to a stop at the screen edge instead of jittering or snapping back
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-06-23
4
+ ---
5
+
6
+ Clips for Slack is here — connect a workspace under Settings, then paste any public clip link in Slack and it unfurls as a playable video
@@ -0,0 +1,72 @@
1
+ # Permissions model
2
+
3
+ This extension ships **activeTab-only**: it deliberately does **not** request the
4
+ broad `<all_urls>` host permission, and it has **no declarative `content_scripts`**.
5
+
6
+ ## Why
7
+
8
+ A declarative content script on `<all_urls>` (and the matching `<all_urls>` host
9
+ permission) triggers Chrome Web Store's **broad host permission in-depth review**,
10
+ which significantly delays publishing and updates. We avoid that.
11
+
12
+ ## How the overlay gets on the page
13
+
14
+ When the user clicks the extension and starts a recording, the background service
15
+ worker injects the content script into the **launch tab** with
16
+ `chrome.scripting.executeScript({ target: { tabId }, files: ["assets/content-script.js"] })`.
17
+ That call is authorized by the **`activeTab`** permission, which Chrome grants for
18
+ the tab that was active when the user invoked the extension — no broad host access
19
+ needed. The content script then mounts the overlay iframes (countdown, camera
20
+ bubble, controls). `web_accessible_resources` stays `<all_urls>` — that is **not**
21
+ a host permission and does not trigger the review.
22
+
23
+ Declared permissions: `activeTab`, `debugger`, `offscreen`, `scripting`,
24
+ `storage`. Host permissions: only the configured Clips app + `forms.agent-native.com`
25
+
26
+ - `localhost`/`127.0.0.1`.
27
+
28
+ ## What this costs
29
+
30
+ - The overlay (countdown, camera bubble, recording controls) lives **only on the
31
+ tab the recording was launched from**. It does **not** follow the user to other
32
+ tabs during a full-screen or multi-tab recording.
33
+ - **Recording itself is unaffected.** Capture runs in the offscreen document via
34
+ `getDisplayMedia`, independent of any tab's content script — full-screen,
35
+ window, and other-tab content are all still captured normally. Only the on-page
36
+ _overlay UI_ is scoped to the launch tab.
37
+
38
+ ## Re-enabling cross-tab follow
39
+
40
+ The full cross-tab behavior is gated behind a single flag in
41
+ `src/background.ts`:
42
+
43
+ ```ts
44
+ const CROSS_TAB_FOLLOW = false; // flip to true
45
+ ```
46
+
47
+ Flipping it to `true` restores the all-tabs broadcast in `broadcastMount()` /
48
+ `broadcastUnmount()` and the `chrome.tabs.onActivated` follow listener. To make
49
+ that actually work you must **also** restore broad host access so the worker can
50
+ inject into arbitrary tabs. Two options:
51
+
52
+ 1. **Static (simplest, but in-depth review):** re-add to `public/manifest.json`:
53
+ - `"<all_urls>"` in `host_permissions`, and
54
+ - the `content_scripts` block:
55
+ ```json
56
+ "content_scripts": [
57
+ {
58
+ "matches": ["<all_urls>"],
59
+ "js": ["assets/content-script.js"],
60
+ "run_at": "document_idle",
61
+ "all_frames": false
62
+ }
63
+ ],
64
+ ```
65
+
66
+ 2. **Optional (preferred — keeps the default install lean):** declare
67
+ `"optional_host_permissions": ["<all_urls>"]` in the manifest and call
68
+ `chrome.permissions.request({ origins: ["<all_urls>"] })` at runtime (e.g. from
69
+ a settings toggle) before enabling follow. The user grants the broad access
70
+ explicitly, only if they want cross-tab overlays.
71
+
72
+ After any change: `pnpm build`, then re-zip `dist/`.
@@ -23,16 +23,7 @@
23
23
  "https://clips.agent-native.com/*",
24
24
  "https://forms.agent-native.com/*",
25
25
  "http://localhost/*",
26
- "http://127.0.0.1/*",
27
- "<all_urls>"
28
- ],
29
- "content_scripts": [
30
- {
31
- "matches": ["<all_urls>"],
32
- "js": ["assets/content-script.js"],
33
- "run_at": "document_idle",
34
- "all_frames": false
35
- }
26
+ "http://127.0.0.1/*"
36
27
  ],
37
28
  "web_accessible_resources": [
38
29
  {
@@ -210,7 +210,22 @@ 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;
218
+ // Cross-tab follow: when true, the overlay is pushed to whatever tab the user
219
+ // switches to mid-recording. That requires "<all_urls>" + a declarative content
220
+ // script, which triggers Chrome's broad-host in-depth review. Shipped OFF
221
+ // (activeTab-only: the overlay lives on the launch tab the user invoked the
222
+ // extension from). Capture is unaffected — it runs in the offscreen document via
223
+ // getDisplayMedia regardless. See PERMISSIONS.md to re-enable.
224
+ // Typed `boolean` (not inferred literal `false`) so the cross-tab branches below
225
+ // don't read as unreachable code; flip the value to re-enable. See PERMISSIONS.md.
226
+ const CROSS_TAB_FOLLOW: boolean = false;
227
+ // The tab the recording was launched from — the only tab activeTab lets us touch.
228
+ let overlayTabId: number | null = null;
214
229
  let countdownEndsAtMs = 0;
215
230
 
216
231
  function desiredParts(): OverlayPart[] {
@@ -221,7 +236,7 @@ function desiredParts(): OverlayPart[] {
221
236
  return overlayShowsBubble ? ["bubble", "countdown"] : ["countdown"];
222
237
  }
223
238
  if (overlayPhase === "recording" || overlayPhase === "paused") {
224
- return overlayShowsBubble ? ["bubble", "toolbar"] : ["toolbar"];
239
+ return recordingShowsBubble ? ["bubble", "toolbar"] : ["toolbar"];
225
240
  }
226
241
  if (overlayPhase === "saving") {
227
242
  return ["saving"];
@@ -286,7 +301,9 @@ function persistOverlay(): Promise<void> {
286
301
  baseElapsedMs: overlayBaseElapsedMs,
287
302
  baseEpochMs: overlayBaseEpochMs,
288
303
  showsBubble: overlayShowsBubble,
304
+ recordingShowsBubble,
289
305
  countdownEndsAtMs,
306
+ overlayTabId,
290
307
  },
291
308
  }).catch(() => undefined);
292
309
  }
@@ -306,7 +323,9 @@ async function restoreRuntimeState(): Promise<void> {
306
323
  baseElapsedMs?: number;
307
324
  baseEpochMs?: number;
308
325
  showsBubble?: boolean;
326
+ recordingShowsBubble?: boolean;
309
327
  countdownEndsAtMs?: number;
328
+ overlayTabId?: number;
310
329
  }
311
330
  | undefined;
312
331
  if (rt && typeof rt.phase === "string" && overlayPhase === "idle") {
@@ -316,8 +335,10 @@ async function restoreRuntimeState(): Promise<void> {
316
335
  overlayBaseEpochMs =
317
336
  typeof rt.baseEpochMs === "number" ? rt.baseEpochMs : 0;
318
337
  overlayShowsBubble = Boolean(rt.showsBubble);
338
+ recordingShowsBubble = Boolean(rt.recordingShowsBubble);
319
339
  countdownEndsAtMs =
320
340
  typeof rt.countdownEndsAtMs === "number" ? rt.countdownEndsAtMs : 0;
341
+ overlayTabId = typeof rt.overlayTabId === "number" ? rt.overlayTabId : null;
321
342
  }
322
343
 
323
344
  if (overlayPhase !== "idle" && activeNativeRecording) {
@@ -386,6 +407,13 @@ function allTabs(): Promise<chrome.tabs.Tab[]> {
386
407
  }
387
408
 
388
409
  async function broadcastMount(): Promise<void> {
410
+ // activeTab-only: keep the overlay on the launch tab (re-ensures the injected
411
+ // content script too, so it survives a worker suspend). Cross-tab broadcast is
412
+ // gated behind CROSS_TAB_FOLLOW because it needs broad host access.
413
+ if (!CROSS_TAB_FOLLOW) {
414
+ if (overlayTabId !== null) await mountOverlayOnTab(overlayTabId);
415
+ return;
416
+ }
389
417
  const parts = desiredParts();
390
418
  const tabs = await allTabs();
391
419
  await Promise.all(
@@ -398,6 +426,11 @@ async function broadcastMount(): Promise<void> {
398
426
  }
399
427
 
400
428
  async function broadcastUnmount(): Promise<void> {
429
+ if (!CROSS_TAB_FOLLOW) {
430
+ if (overlayTabId !== null)
431
+ await sendTabMessage(overlayTabId, { type: "CLIPS_OVERLAY_UNMOUNT" });
432
+ return;
433
+ }
401
434
  const tabs = await allTabs();
402
435
  await Promise.all(
403
436
  tabs.map((tab) =>
@@ -413,6 +446,7 @@ function resetOverlay(): void {
413
446
  overlayBaseElapsedMs = 0;
414
447
  overlayBaseEpochMs = 0;
415
448
  overlayShowsBubble = false;
449
+ recordingShowsBubble = false;
416
450
  clearCountdownTimer();
417
451
  setRecordingFlag(false);
418
452
  // Bring back the toolbar popup now that we're idle again.
@@ -420,6 +454,41 @@ function resetOverlay(): void {
420
454
  void persistOverlay();
421
455
  }
422
456
 
457
+ // ----- Pre-record camera preview --------------------------------------------
458
+ // While the popup is open and idle, show the live face bubble on the active tab
459
+ // (like the desktop app's pre-record bubble). The popup holds a runtime port
460
+ // open; its disconnect (popup closed) tears the preview down.
461
+ let previewTabId: number | null = null;
462
+
463
+ async function startPreview(wantsCamera: boolean): Promise<void> {
464
+ await ensureRestored();
465
+ // Never show a preview over an active recording (the recording overlay owns
466
+ // the tab then). If the camera is off, make sure any preview is removed.
467
+ if (overlayPhase !== "idle" || !wantsCamera) {
468
+ await stopPreview();
469
+ return;
470
+ }
471
+ const tab = await queryActiveTab();
472
+ if (!tab || typeof tab.id !== "number") return;
473
+ if (previewTabId !== null && previewTabId !== tab.id) await stopPreview();
474
+ previewTabId = tab.id;
475
+ await ensureContentScript(tab.id);
476
+ // Injection / send fail silently on unsupported pages (chrome://, etc.).
477
+ await sendTabMessage(tab.id, {
478
+ type: "CLIPS_OVERLAY_MOUNT",
479
+ parts: ["bubble"],
480
+ });
481
+ }
482
+
483
+ async function stopPreview(): Promise<void> {
484
+ const tabId = previewTabId;
485
+ previewTabId = null;
486
+ if (tabId === null) return;
487
+ // Don't tear down a recording overlay that took over this tab.
488
+ if (overlayPhase !== "idle") return;
489
+ await sendTabMessage(tabId, { type: "CLIPS_OVERLAY_UNMOUNT" });
490
+ }
491
+
423
492
  // Mirrored into chrome.storage.local so content scripts can cheaply tell whether
424
493
  // a recording is in progress without waking the service worker on every page
425
494
  // load. Content scripts can read storage.local by default; storage.session
@@ -875,6 +944,7 @@ async function armRecording(args: {
875
944
  settings.captureSurface === "camera" ? "camera" : "screen";
876
945
  const surface: "browser" | "window" | "monitor" =
877
946
  settings.captureSurface === "camera" ? "browser" : settings.captureSurface;
947
+ console.log("[clips-bg] arm: start", { mode, surface, tabId: tab.id });
878
948
 
879
949
  // Pre-warmed on popup open, so this is effectively instant and keeps the
880
950
  // getDisplayMedia() call close to the user's click.
@@ -882,15 +952,52 @@ async function armRecording(args: {
882
952
 
883
953
  // 1) Native "Choose what to share" picker. Do this before any network round
884
954
  // trip so the picker stays tied to the user gesture. Throws if cancelled.
885
- await sendOffscreenMessage<{ ok: boolean; width: number; height: number }>({
955
+ const acq = await sendOffscreenMessage<{
956
+ ok: boolean;
957
+ width: number;
958
+ height: number;
959
+ }>({
886
960
  type: "CLIPS_OFFSCREEN_ACQUIRE",
887
961
  sessionId,
888
962
  mode,
889
963
  surface,
890
964
  includeMicrophone: settings.includeMicrophone,
965
+ includeCamera: settings.includeCamera,
891
966
  });
967
+ console.log("[clips-bg] arm: acquired stream", acq);
968
+
969
+ // 2) Show the countdown overlay IMMEDIATELY — before the network round-trip —
970
+ // so the 3-2-1 runs full-length and the dim/blur clears exactly when the
971
+ // recorder starts. The on-page bubble shows during countdown AND recording
972
+ // (the face is captured in the display; we do not composite).
973
+ const cameraInvolved = mode === "camera" || settings.includeCamera;
974
+ overlayPhase = "countdown";
975
+ overlayBaseElapsedMs = 0;
976
+ overlayBaseEpochMs = nowMs();
977
+ overlayShowsBubble = cameraInvolved;
978
+ recordingShowsBubble = cameraInvolved;
979
+ countdownEndsAtMs = nowMs() + COUNTDOWN_SECONDS * 1000;
980
+ // The recording overlay now owns this tab's bubble — hand off from any preview
981
+ // so the popup-close disconnect won't tear down the live recording overlay.
982
+ previewTabId = null;
983
+ setRecordingFlag(true);
984
+ // Clicking the icon now stops & saves immediately instead of opening the popup.
985
+ setActionPopup("");
986
+ overlayTabId = tab.id as number;
987
+ await mountOverlayOnTab(tab.id as number);
988
+ broadcastOverlayState();
989
+
990
+ const abortArming = async (): Promise<void> => {
991
+ await sendOffscreenMessage({
992
+ type: "CLIPS_OFFSCREEN_CANCEL",
993
+ sessionId,
994
+ }).catch(() => undefined);
995
+ resetOverlay();
996
+ await broadcastUnmount();
997
+ await chrome.action.setBadgeText({ text: "" });
998
+ };
892
999
 
893
- // 2) Create the recording row now that we know the user picked a source.
1000
+ // 3) Create the recording row (happens during the countdown).
894
1001
  type CreatedRecording = {
895
1002
  id?: string;
896
1003
  uploadChunkUrl?: string;
@@ -903,23 +1010,17 @@ async function armRecording(args: {
903
1010
  titleSource: tab.title ? "context" : "default",
904
1011
  sourceAppName: "Chrome",
905
1012
  sourceWindowTitle: tab.title ?? null,
906
- hasCamera: settings.captureSurface === "camera" || settings.includeCamera,
1013
+ hasCamera: cameraInvolved,
907
1014
  hasAudio:
908
1015
  settings.includeMicrophone || settings.captureSurface !== "camera",
909
1016
  visibility: "public",
910
1017
  });
911
1018
  } catch (err) {
912
- await sendOffscreenMessage({
913
- type: "CLIPS_OFFSCREEN_CANCEL",
914
- sessionId,
915
- }).catch(() => undefined);
1019
+ await abortArming();
916
1020
  throw err;
917
1021
  }
918
1022
  if (!created.id || !created.uploadChunkUrl) {
919
- await sendOffscreenMessage({
920
- type: "CLIPS_OFFSCREEN_CANCEL",
921
- sessionId,
922
- }).catch(() => undefined);
1023
+ await abortArming();
923
1024
  throw new Error("Clips did not create a recording target.");
924
1025
  }
925
1026
 
@@ -946,35 +1047,29 @@ async function armRecording(args: {
946
1047
  error: null,
947
1048
  };
948
1049
 
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.).
1050
+ // 4) Start the recorder when the (already-running) countdown ends. The
1051
+ // offscreen owns the pre-roll timer (a reliable context, unlike the
1052
+ // suspendable worker) and reports "recording" back when it actually starts.
966
1053
  const authToken = (await readAuthSession(settings))?.token;
1054
+ console.log("[clips-bg] arm: created row", created.id, "auth?", !!authToken);
1055
+ // The on-page countdown drives the actual start (it sends COUNTDOWN_DONE at
1056
+ // "Go"). This offscreen timer is only a FALLBACK. When a camera is involved the
1057
+ // countdown waits for the camera feed before it even shows "3" (the content
1058
+ // script holds it, with its own 12s cap), so the fallback must be generous
1059
+ // enough not to start the recorder mid-connect; otherwise a short pre-roll.
1060
+ const startDelayMs = cameraInvolved ? 20000 : COUNTDOWN_SECONDS * 1000 + 1000;
967
1061
  try {
968
1062
  await sendOffscreenMessage({
969
1063
  type: "CLIPS_OFFSCREEN_BEGIN",
970
1064
  sessionId,
971
1065
  recordingId: created.id,
972
1066
  uploadUrl,
973
- hasCamera: mode === "camera" || settings.includeCamera,
974
- startDelayMs: COUNTDOWN_SECONDS * 1000,
1067
+ hasCamera: cameraInvolved,
1068
+ startDelayMs,
975
1069
  authToken,
976
1070
  });
977
1071
  } catch (err) {
1072
+ console.error("[clips-bg] arm: BEGIN failed", err);
978
1073
  await cancelRecording();
979
1074
  throw err;
980
1075
  }
@@ -985,10 +1080,10 @@ async function armRecording(args: {
985
1080
  await attachSession(session);
986
1081
  }
987
1082
 
988
- await mountOverlayOnTab(tab.id as number);
989
1083
  broadcastOverlayState();
990
1084
  await chrome.action.setBadgeBackgroundColor({ color: "#e11d48" });
991
1085
  await chrome.action.setBadgeText({ text: "REC" });
1086
+ console.log("[clips-bg] arm: done — countdown, parts:", desiredParts());
992
1087
 
993
1088
  return { ok: true, sessionId, recordingId: created.id, native: true };
994
1089
  }
@@ -998,6 +1093,7 @@ async function armRecording(args: {
998
1093
  // on-page timer. Idempotent.
999
1094
  async function markRecordingStarted() {
1000
1095
  if (overlayPhase !== "countdown") return;
1096
+ console.log("[clips-bg] recorder started → phase=recording");
1001
1097
  clearCountdownTimer();
1002
1098
  overlayPhase = "recording";
1003
1099
  overlayBaseElapsedMs = 0;
@@ -1178,6 +1274,7 @@ async function finishSaving(
1178
1274
 
1179
1275
  async function stopRecording() {
1180
1276
  const recording = activeNativeRecording;
1277
+ console.log("[clips-bg] stopRecording — active:", !!recording);
1181
1278
  if (!recording) return { ok: false, error: "No active Clips recording." };
1182
1279
  recording.status = "stopping";
1183
1280
  // Keep an overlay up: swap the recording controls/bubble for a "Saving…" card
@@ -1821,6 +1918,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1821
1918
  try {
1822
1919
  response = await dispatchRuntimeMessage(message);
1823
1920
  } catch (err) {
1921
+ console.error(
1922
+ "[clips-bg] message failed:",
1923
+ (message as { type?: unknown })?.type,
1924
+ err,
1925
+ );
1824
1926
  response = {
1825
1927
  ok: false,
1826
1928
  error: err instanceof Error ? err.message : "Could not use Clips.",
@@ -1841,6 +1943,13 @@ async function dispatchRuntimeMessage(message: unknown): Promise<unknown> {
1841
1943
  // Status updates streamed from the offscreen recorder.
1842
1944
  if (type === "CLIPS_NATIVE_STATUS") {
1843
1945
  const status = message as OffscreenStatusMessage;
1946
+ console.log(
1947
+ "[clips-bg] status:",
1948
+ status.status,
1949
+ status.error ?? "",
1950
+ "phase:",
1951
+ overlayPhase,
1952
+ );
1844
1953
  if (
1845
1954
  activeNativeRecording &&
1846
1955
  activeNativeRecording.sessionId === status.sessionId
@@ -1909,6 +2018,11 @@ async function dispatchRuntimeMessage(message: unknown): Promise<unknown> {
1909
2018
  return { ok: true };
1910
2019
  }
1911
2020
 
2021
+ if (type === "CLIPS_PREVIEW") {
2022
+ await startPreview(Boolean((message as { camera?: unknown }).camera));
2023
+ return { ok: true };
2024
+ }
2025
+
1912
2026
  switch (type) {
1913
2027
  case "CLIPS_POPUP_START":
1914
2028
  return handlePopupStart(message as PopupStartMessage);
@@ -1946,6 +2060,7 @@ async function dispatchRuntimeMessage(message: unknown): Promise<unknown> {
1946
2060
  // switch tabs (programmatic injection covers tabs opened before the extension
1947
2061
  // loaded; declared content scripts cover navigations).
1948
2062
  chrome.tabs.onActivated.addListener((info) => {
2063
+ if (!CROSS_TAB_FOLLOW) return; // activeTab-only: overlay stays on the launch tab
1949
2064
  void (async () => {
1950
2065
  await ensureRestored();
1951
2066
  if (overlayPhase === "idle" || !activeNativeRecording) return;
@@ -1961,6 +2076,12 @@ chrome.action.onClicked.addListener(() => {
1961
2076
  // Await restore — the click may have woken the worker, leaving the module
1962
2077
  // globals empty until this resolves. Without it, Stop silently does nothing.
1963
2078
  await ensureRestored();
2079
+ console.log(
2080
+ "[clips-bg] icon clicked — phase:",
2081
+ overlayPhase,
2082
+ "active:",
2083
+ !!activeNativeRecording,
2084
+ );
1964
2085
  if (!activeNativeRecording) return;
1965
2086
  if (overlayPhase === "countdown") await cancelRecording();
1966
2087
  else if (overlayPhase === "recording" || overlayPhase === "paused") {
@@ -1969,6 +2090,16 @@ chrome.action.onClicked.addListener(() => {
1969
2090
  })();
1970
2091
  });
1971
2092
 
2093
+ // The popup opens a port for as long as it is visible; its disconnect (popup
2094
+ // closed) is our reliable signal to remove the pre-record preview bubble.
2095
+ chrome.runtime.onConnect.addListener((port) => {
2096
+ if (port.name !== "clips-preview") return;
2097
+ port.onDisconnect.addListener(() => {
2098
+ void chrome.runtime.lastError;
2099
+ void stopPreview();
2100
+ });
2101
+ });
2102
+
1972
2103
  chrome.runtime.onMessageExternal.addListener(
1973
2104
  (message, sender, sendResponse) => {
1974
2105
  void handleExternalMessage(message as ExternalMessage, sender)