Skip to content

Skilly browser extension: shared browser-core, /api/extension/* routes, and the WXT extension shell - #46

Merged
engmsaleh merged 68 commits into
mainfrom
feature/extension-groundwork
Aug 1, 2026
Merged

Skilly browser extension: shared browser-core, /api/extension/* routes, and the WXT extension shell#46
engmsaleh merged 68 commits into
mainfrom
feature/extension-groundwork

Conversation

@engmsaleh

Copy link
Copy Markdown
Collaborator

Three plans, landed end to end.

Plan 1 — sdk/browser-core: digest / pointing / actions / prompt / realtime extracted out of sdk/web into a package shared with the extension; PointingEngine decoupled from the concrete widget via CursorHost.

Plan 2 — /api/extension/*: shared signed-token primitives, an extension session isolated from the Mac audience, and the auth-exchange / entitlement / openai-token / usage routes. Entitlement reuses mac_entitlements keyed by WorkOS user id, so one subscription unlocks Mac and extension with no schema change.

Plan 3 — sdk/extension: WXT MV3 extension (Chrome + Firefox) — content script with per-frame digest and cursor, background coordinator, Realtime session host, popup.

Verified

  • 43 extension unit tests + 4 Playwright e2e (incl. a genuinely cross-origin iframe), 126 backend tests, 18 browser-core tests
  • tsc clean across all packages; both browser targets build
  • Full auth round-trip against real WorkOS + Postgres: exchange 200, entitlement active, a real Realtime secret minted, usage row stored — with code-replay, forged-token and no-auth all 401

Notable fixes beyond the plans

  • Cross-frame routing keyed on sender.frameId, not WXT's ctx.instanceId (a random per-injection id, so pointing into iframes would silently never have worked)
  • Skill URL matching at a dot boundary — substring matching handed figma.com.attacker.net the real skill
  • The widget mounts under data-skilly-widget so it is excluded from its own digest
  • Session teardown resolves in-flight actions instead of leaking promises; startSession re-checks the active tab after every await
  • Firefox hosts the session in its event page rather than a Chrome-only offscreen document

Before this can serve traffic

Production is missing SESSION_TOKEN_SECRET and an extension OpenAI key (OPENAI_API_KEY_EXTENSION or OPENAI_API_KEY). Without them /api/extension/auth/exchange throws and /openai/token returns 500.

Firefox voice is architecturally unblocked but unverified at runtime (needs a real browser + microphone).

🤖 Generated with Claude Code

engmsaleh and others added 30 commits July 7, 2026 00:54
The web widget could only point and talk; the last-mile step was still on
the user. This adds a perform_action tool to the existing Realtime session
(one brain, no second LLM loop) executed by a new ActionExecutor over the
digest element registry, with the cursor flying to the target first.

Safety is executor-enforced, never model-trusted: default-off config,
confirm-by-default chip (skipped only on tenant-annotated data-skilly
elements with a clean flag and label), data-skilly-no-act refusal, atomic
per-turn action cap reset at the user-turn boundary, and fill restricted
to text-like inputs with an output always sent back so the turn never
hangs. Cross-model review (3 findings) hardened the cap atomicity, the
confirmation default, and the fill path.

PRD: docs/architecture/web-actions-prd.md (Studio config + metering is 10.1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…10.1)

The Actions feature was gated by a local script attribute, which the site
owner can't manage and Skilly can't observe. The tenant widget config now
owns actionsEnabled (Studio toggle, default off), served to the embed in
the existing token response so no extra round trip is added; in live mode
the server flag decides and an explicit local false remains a kill-switch.
Older backends without the field mean off, older SDKs simply ignore it.

Executed/refused action counters ride the existing session-end usage
report; usage_events gains a nullable count column (migration 0006) and
one kind:"action" row per report feeds a total-actions metric on the
usage page. Metering stays honor-system by design — flagged again in
cross-review and tracked as cross-cutting hardening in PRD §11.6 since
seconds share the same trust model and gate quota, actions do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.agents/ holds internal working docs that can contain customer PII, and this
repo is public. It was sitting untracked-but-unignored, one `git add -A` away
from being published. Never committed (verified: no history), so ignoring it is
sufficient — no history rewrite needed. Same rule already applied in
skilly-videos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`xcodebuild test` could not run a single test. Six independent breakages, four
of them from one root cause: the app was renamed leanring-buddy -> Skilly and
the test target was never updated. Nothing caught it because nothing ever ran
the tests — broken tests can't fail if they never compile.

1. No shared .xcscheme was committed (only xcuserdata plists pointing at a
   "shared" scheme absent from disk), so xcodebuild autocreated one without the
   test target: "leanring-buddyTests isn't a member of the specified test plan
   or scheme". Adds xcshareddata/xcschemes/leanring-buddy.xcscheme with the unit
   test target wired into the Test action. UITests are deliberately left out:
   they launch the app and are slow/flaky, which is how a suite gets switched
   off again.
2. TEST_HOST still pointed at leanring-buddy.app/.../leanring-buddy while
   PRODUCT_NAME is Skilly -> "Could not find test host". Now Skilly.app/.../Skilly.
3. SkillPromptComposerTests: `let (skill, var progress) = ...` — `var` in
   patterns was removed in Swift 3. This has never compiled under Swift 5.
4. All test files did `@testable import leanring_buddy`, but PRODUCT_MODULE_NAME
   is unset so the module follows PRODUCT_NAME and is now `Skilly`. Fixed on the
   test side rather than pinning PRODUCT_MODULE_NAME back, which would change the
   shipping app's module name (affects @objc names / archived data) to save edits.
5. leanring_buddyTests called @mainactor statics from non-isolated tests.
6. SkillDefinitionTests missed `import Foundation` — no longer transitive under
   SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES.

Verified: `xcodebuild test -scheme leanring-buddy -destination platform=macOS`
now builds and runs the suite. Note CI needs a Mac Development cert for team
6D7X9GGZAW, or CODE_SIGNING_ALLOWED=NO for unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aller

SkillyAnalytics.trackSubscriptionActivated() has existed since launch with ZERO
callers. startPostCheckoutPolling() detected the active subscription and only
did a `#if DEBUG print`, so in a release build the single moment a customer pays
emitted nothing at all. PostHog confirms: skilly_subscription_activated has
never fired once in 120 days.

Consequence beyond the missing event: the weekly "checkout_started without
subscription_activated" funnel check could never return anything but 100%
unconverted — it has been measuring a metric that cannot fire. 8 people reached
Polar checkout (73 events) and we had no way to know whether any of them paid.

Fired from refresh() rather than from the polling function, because refresh() is
the one path every activation routes through — app launch, checkout return, and
the 5s post-checkout poll all call it. Fixing only the poll would still miss a
payment confirmed after the 2-minute window closes, or on a later cold start.

The latch (claimActivationLatch) is persisted per-user in UserDefaults, mirroring
TrialTracker's milestone flags. refresh() runs on every launch and every 5s while
polling, so without it the event would trade "zero events" for an endless stream
of duplicates — arguably worse, since duplicates look like it works.

TrialTracker.recordConversionToPaid() deliberately keeps its hasRecordedTrialStarted
gate: trial_converted_to_paid is specifically a *trial* metric. The new event is
not — someone who pays without ever starting a trial is still an activation.

Known limitation: if analyticsEnabled is false the latch burns without emitting.
Acceptable — for an opted-out user we don't want the event anyway.

Verified: 3 tests pass (`xcodebuild test -only-testing:leanring-buddyTests/
EntitlementActivationLatchTests`) — fires once despite repeat polls, latches per
user not globally, and survives relaunch.

This fixes measurement going FORWARD only. It recovers nothing historical; Polar
remains the source of truth for whether revenue has ever arrived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…omer

Gabriel Blanco subscribed to Skilly Pro (first real paying customer, 2026-07-23),
Polar showed the subscription active and the $19 order paid — but the Mac app kept
showing him as unpaid.

Root cause: a date-precision mismatch across the two sides of the entitlement.
Polar sends `current_period_end` with MICROSECOND precision
("2026-08-23T12:06:03.691165Z"). The worker passed it through verbatim into the KV
entitlement record. The Mac app gates access via ISO8601DateFormatter, which only
accepts up to millisecond (3-digit) fractional seconds and returns nil on more —
so EntitlementRecord.parsedStatus fell through `if let end = parseISO8601(...)` to
`return .none`. An active subscription read as "no subscription."

Everything upstream was correct: payment real, webhook delivered, worker returned
200, KV record written with status:active. Only those 3 extra fractional digits
broke it. Since Gabriel is the first real external customer (the only prior
conversion was the founder's own test on the day the webhook was added), this path
had never actually worked end-to-end.

Two-layer fix:
- worker: `toMillisIso()` normalises every date entering the entitlement record to
  millisecond precision (new Date(x).toISOString()). Deployed (version 1858d562).
- Mac app: parseISO8601 now truncates 4+ fractional digits to 3 and retries, so no
  upstream precision change can ever gate access again. Regression test added
  (EntitlementDateParsingTests) covering none/ms/micro/nanosecond precision.

Gabriel's live KV record was also corrected in place (period_end truncated to ms)
so his already-installed app reads .active on next poll — no app update required
of him.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `"error"` event from OpenAI Realtime was only printed in DEBUG, so
session-level rejections were invisible in production. That blind spot hid two
outages: the shared-key OpenAI credit going negative (2026-07-23, hosted voice
down fleet-wide) and the earlier beta_api_shape_disabled rejection that left 17
users with zero messages for 14 days.

Now fires trackSilentFailure(subsystem: "openai_realtime_session", ...) with the
error `code` (e.g. "insufficient_quota" — the negative-credit signal) so these
surface in PostHog within minutes. Client-side, so it survives the planned
backend consolidation off the temporary worker.

Pair with a PostHog alert on this event + OpenAI-native auto-recharge/usage
alerts (OpenAI is the only source that knows the balance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Mac Worker now mints ephemeral OpenAI Realtime client secrets instead of returning the raw server key, while preserving the existing response contract. Studio can optionally use isolated Web/Demo OpenAI keys and returns a tenant guest-session cap to the widget. The new cap defaults to zero, and Postgres reads/writes tolerate the column being absent so production remains safe during staggered migration windows.

Constraint: First paying customer must not be interrupted during deployment

Constraint: New OpenAI per-surface keys may not exist yet, so all paths keep fallback behavior

Rejected: Require DB migration before deploying Studio | token and dashboard paths would be more fragile during rollout

Rejected: Gate current customers with a default guest cap | would change existing runtime behavior

Confidence: high

Scope-risk: moderate

Directive: Keep guestSessionCapSeconds defaulting to 0 unless a tenant explicitly opts into a per-session cap

Tested: apps/web-backend bun test 89/89

Tested: apps/web-backend typecheck and production build

Tested: sdk/web bun test 26/26, typecheck, and build

Tested: Worker wrangler deploy and live unauthenticated /openai/token returned 401

Tested: Studio production health returned 200 and demo token mint returned the expected shape

Not-tested: Authenticated Mac app Realtime turn with a real customer session token
The Worker now attempts the isolated OPENAI_API_KEY_MAC first, then falls back to the existing OPENAI_API_KEY if the new key cannot mint an ephemeral Realtime secret. This lets us add and test the new Mac key without making the first customer dependent on a single fresh secret being correct.

Constraint: Current Mac customer must not be interrupted by OpenAI key rotation

Rejected: Prefer OPENAI_API_KEY_MAC unconditionally | a bad or over-limited new key would turn rotation into an outage

Confidence: high

Scope-risk: narrow

Directive: Remove fallback only after OPENAI_API_KEY_MAC has been live and monitored through real Mac sessions

Tested: wrangler deploy --dry-run

Tested: wrangler deploy production version 8d667696-bc2e-4115-a847-0600d8721b8a

Tested: unauthenticated /openai/token returned 401
Studio now exposes Mac-compatible token, entitlement, and usage endpoints that accept the existing Worker-issued desktop session JWT. The routes are additive and protected; current Mac builds still use Cloudflare until a later app update switches them to Studio with fallback.

Constraint: Do not interrupt the first Mac customer during backend migration

Constraint: Studio must accept the Worker session-token contract before any app cutover

Rejected: Switch the Mac app directly to Studio | no customer-safe fallback or entitlement parity yet

Confidence: high

Scope-risk: moderate

Directive: Keep Cloudflare fallback in the Mac app until Studio entitlement parity has been verified against real Worker KV records

Tested: apps/web-backend bun test 91/91

Tested: apps/web-backend typecheck and production build

Tested: Neon migration applied and mac_entitlements/mac_usage_events tables verified

Tested: Vercel production deployment dpl_AKgprKm3kUDLtmFWUZwhU5GigvDV

Tested: Live /api/health returned 200; unauthenticated /api/mac routes returned 401

Not-tested: Authenticated Mac app token mint using a real desktop session token
The Studio backend can now receive Mac traffic, but the cutover should not happen without proving the old Worker entitlement state is either empty or copied into Postgres. Add dry-run-first scripts to migrate Worker KV entitlement rows and compare Worker KV against Studio mac_entitlements before changing the Mac app base URL.

Constraint: Current Mac customer must stay on the existing Cloudflare path until parity is proven.

Rejected: Cut Mac directly to Studio after adding token routes | entitlement drift could block or downgrade an active customer.

Confidence: high

Scope-risk: narrow

Directive: Run check:mac-entitlement-parity before any Mac client cutover; run migrate:mac-entitlements --apply only after dry-run output is reviewed.

Tested: bun test; bun run build; bun run typecheck; migrate:mac-entitlements --dry-run; check:mac-entitlement-parity

Not-tested: Non-empty Worker KV migration apply path, because production KV currently has zero user entitlement records.
The Mac app needs a path onto Studio without risking the current customer. Keep Worker as the default and source-of-truth fallback while adding an opt-in Studio Mac backend path for Realtime token minting, entitlement reads, and usage telemetry.

Constraint: WorkOS auth and Polar checkout still live on the Cloudflare Worker.

Constraint: Studio currently has no migrated entitlement rows, so a Studio none response must not override Worker entitlement.

Rejected: Change workerBaseURL to Studio | auth and checkout routes would break immediately.

Rejected: Trust Studio entitlement none during rollout | could downgrade an active customer if parity is incomplete.

Confidence: high

Scope-risk: moderate

Directive: Do not default useStudioMacBackend to true until entitlement parity is proven and a signed Mac build has been smoke-tested.

Tested: bun test; bun run build; bun run typecheck; xcodebuild -project leanring-buddy.xcodeproj -scheme leanring-buddy -configuration Debug -derivedDataPath /tmp/skilly-derived-data CODE_SIGNING_ALLOWED=NO build

Not-tested: Live authenticated Mac turn through Studio toggle; signed/notarized release build.
Studio production does not have the Worker's SESSION_TOKEN_SECRET, so existing Mac session tokens returned HTTP 500 during the local debug-app smoke. Add a migration bridge that first verifies locally when the shared secret exists, then validates the bearer token against the Worker entitlement route and only trusts the decoded session after the Worker accepts the same token and user id.

Constraint: Existing Mac users must not re-login during the Studio migration.

Constraint: The Worker validates bearer tokens and rejects user_id mismatch on /entitlement.

Rejected: Copy a new session secret into Studio | it would not match already-issued Mac tokens unless the exact Worker secret is known.

Rejected: Trust unsigned JWT payloads directly | a forged token could choose any user id.

Confidence: high

Scope-risk: moderate

Directive: Remove the Worker-validation fallback only after Studio owns Mac auth/session issuance or shares the exact Worker session secret.

Tested: bun test; bun run build; bun run typecheck

Not-tested: Live Vercel authenticated token mint after deploy.
The Studio migration smoke exposed a bad user experience: a token-mint 401 immediately called signOut(), deleting Keychain state and forcing the panel back to sign-in. During fallback migration this is too destructive, so the push-to-talk error path now attempts a WorkOS refresh first and only clears the session if refresh fails.

Constraint: Current customer must not be forced through sign-in because of a transient Studio/Worker token fallback issue.

Rejected: Keep immediate signOut on authExpired | it destroys recoverable sessions and makes smoke testing look like logout churn.

Confidence: high

Scope-risk: narrow

Directive: Keep destructive signOut behind a failed refresh, not the first token-mint failure.

Tested: xcodebuild -project leanring-buddy.xcodeproj -scheme leanring-buddy -configuration Debug -derivedDataPath /tmp/skilly-derived-data CODE_SIGNING_ALLOWED=NO build

Not-tested: Live push-to-talk after user signs in again.
BYOK users bypass the Skilly relay and local trial accounting, but we still need product telemetry for session activity. Report every signed-in Mac session to Studio's Mac usage endpoint, label BYOK sessions distinctly, and keep local paid/trial counters unchanged for BYOK.

Constraint: BYOK OpenAI spend remains billed to the user's own OpenAI key.

Constraint: Studio token/entitlement cutover must stay separate from telemetry reporting.

Rejected: Gate BYOK telemetry behind useStudioMacBackend | that toggle controls token/entitlement migration and would hide BYOK usage when customers remain on Worker.

Rejected: Count BYOK seconds against trial or paid caps | current BYOK product behavior bypasses those gates until the paid BYOK subscription is implemented.

Confidence: high

Scope-risk: narrow

Directive: Preserve the byok_completed result marker unless the backend schema gets a first-class source column.

Tested: xcodebuild -project leanring-buddy.xcodeproj -scheme leanring-buddy -configuration Debug -derivedDataPath /tmp/skilly-derived-data CODE_SIGNING_ALLOWED=NO build

Not-tested: Live BYOK turn with a user-supplied OpenAI key.
Mac relay users already depend on the existing Worker and Studio fallback path, so this keeps enforcement behavior unchanged while adding the backend contract needed for BYOK platform billing and richer usage reporting. Mac usage is still best-effort, and production deployment tolerates missing telemetry columns during rollout.

Constraint: Existing customer sessions must keep working during Cloudflare-to-Studio migration.

Constraint: BYOK billing product id is not configured in Vercel yet, so checkout support must fail closed without affecting relay usage.

Rejected: Enforce BYOK subscription immediately | would risk locking users out before the Polar BYOK product and app UI are confirmed.

Confidence: high

Scope-risk: moderate

Directive: Do not enable mandatory BYOK gating until POLAR_MAC_BYOK_PRODUCT_ID is configured and the Mac UI has a tested checkout/restore path.

Tested: bun test; bun run build; xcodebuild -project leanring-buddy.xcodeproj -scheme leanring-buddy -configuration Debug -derivedDataPath /private/tmp/skilly-derived-data build; production DB migration; Vercel production deploy; production health and unauthenticated Mac endpoint smoke checks.

Not-tested: Authenticated BYOK checkout because the Polar BYOK product id is not configured in Vercel.
Mac users can now open the Studio BYOK checkout from Settings after saving an OpenAI key, while the actual BYOK subscription gate remains controlled by an off-by-default setting. This lets us test checkout and webhook behavior without changing the existing customer-critical relay or BYOK voice paths.

Constraint: Current Mac customer usage must not be interrupted while BYOK billing is still being verified.

Rejected: Require BYOK subscription immediately | checkout token/product/webhook flow has not been verified end to end from the signed-in app.

Confidence: high

Scope-risk: narrow

Directive: Keep requireBYOKSubscription false until authenticated checkout and webhook entitlement updates are verified.

Tested: xcodebuild -project leanring-buddy.xcodeproj -scheme leanring-buddy -configuration Debug -derivedDataPath /private/tmp/skilly-derived-data build

Not-tested: Authenticated BYOK checkout opening because the app was not driven through the signed-in UI in this turn.
The macOS release pipeline generated a Sparkle appcast entry for v2.1 build 12 and pushed it to the repository default branch through the GitHub contents API. Keep the feature branch aligned with that shipped release metadata.

Constraint: Sparkle clients read appcast.xml, so the release metadata must match the uploaded GitHub DMG asset.

Confidence: high

Scope-risk: narrow

Tested: scripts/release.sh 2.1 12 completed; GitHub release v2.1 asset digest matches local Skilly.dmg SHA-256.

Not-tested: In-app Sparkle update prompt on an installed customer machine.
Studio auth was failing with only HTTP status codes in production logs, leaving no way to distinguish expired credentials from invalid client, redirect, or code errors. WorkOS failures now include a bounded sanitized response body while redacting token, secret, code, and key fields.

Constraint: Production login needs diagnostics without leaking auth material into logs.

Rejected: Guess and change WorkOS request parameters | current request shapes match WorkOS documentation, and the observed statuses point to upstream configuration or code lifecycle.

Confidence: high

Scope-risk: narrow

Directive: Keep WorkOS upstream error bodies sanitized; do not log raw auth responses.

Tested: bun test; bun run build

Not-tested: Live failed WorkOS login after deployment.
…ltime text-output rate 16->24

Lets us A/B gpt-realtime-mini via env without code changes. Both clients follow
the server-minted token.model, so no app rebuild needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brainstormed with the user: B2C extension surface reusing ~60% of the
web widget's code (digest/pointing/actions/prompt/realtime/core) via a
new sdk/browser-core package, targeting Chrome/Firefox/Safari via WXT.
Isolated /api/extension/* backend routes share Mac's session-verification
logic without sharing deploy risk with the in-flight Mac/Studio cutover.
Shared WorkOS entitlement with Mac, per-surface usage tagging, loosened
action-confirmation default for self-use with a hard destructive-keyword
floor that never relaxes.

Not yet an implementation plan — docs/superpowers/specs/2026-07-27-chrome-extension-design.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sketch: docs/architecture/chrome-extension-sketch.md — reuse-vs-net-new
breakdown for a Skilly browser extension, requested after the architecture
review.

Plans (companion to the approved design at
docs/superpowers/specs/2026-07-27-chrome-extension-design.md), one per
independent sub-project:
- browser-core-extraction: pull digest/pointing/actions/prompt/realtime/core
  out of the shipped @skilly/web widget into sdk/browser-core
- extension-backend-routes: isolated /api/extension/* routes on Studio,
  sharing macSession.ts's auth pattern without sharing its deploy risk
- extension-shell: the WXT extension itself (Chrome + Firefox), depends on
  the two plans above

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pre-flight review caught a plan-mandated conflict: the original Task 1
duplicated ~25 lines of HMAC sign/verify plumbing into extensionSession.ts
to avoid touching macSession.ts, which a code reviewer applying normal DRY
standards would flag as a defect. Human call: extract the shared primitives
into signedToken.ts instead, with macSession.ts's existing 3-test suite as
the regression guard for that one mechanical refactor. Renumbers Tasks 2-6
accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Move PointingEngine import to top of test file with other imports
- Use beforeEach/afterEach to scope window.innerWidth/innerHeight mock locally
- Store and restore original property descriptors instead of polluting globalThis
- Follow the codebase convention of dependency injection over global mocking
- Track whether window existed before each test via windowExistedBeforeTest flag
- In afterEach, delete (globalThis as any).window entirely if window didn't exist before
- Ensures no fake window object remains in globalThis after test completes
- Prevents latent traps for tests that assume real browser environment
- Previously only deleted properties, leaving empty window object behind
…re paywall_shown

- OpenAIRealtimeClient: on a 401, refresh the session token and retry inline so
  the current push-to-talk succeeds (v2.1 only recovered after the failed press).
  Falls back to authExpired -> sign-out + re-login prompt if the refresh token is dead.
- SkillyAnalytics: gate session_ended + turn_completed on analyticsEnabled only
  (drop beta_terms_consent, which defaulted false and kept both events dead).
- PlanStrip/CapReached/SubscriptionRequired/TrialExhausted: fire skilly_paywall_shown
  with a per-surface reason on appear.

Reverted an out-of-scope Codex change that rewrote the subscription_activated
persisted latch and renamed the cap event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
engmsaleh and others added 16 commits July 29, 2026 21:41
startSparkleUpdater() has been commented out since aebc793 'Open-source Clicky',
so no released build (2.0-2.2) ever checked the appcast — the whole update
pipeline reached nobody. Uncomment the call; the #if DEBUG guard still skips it
in the Skilly Dev build. Existing users need a one-time manual install of the
first build that has this; auto-update works for them from then on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
requestForcedSpokenResponse still sent the deprecated response.modalities field;
OpenAI GA rejects it ('Unknown parameter: response.modalities'), so the recovery
that forces the model to SPEAK after a silent tool-call (point-at-element) failed —
users saw the cursor move with no voice explanation. Switch to output_modalities:
[audio], matching session.update (L497) and the main response.create (L610) and
OpenAI's current GA schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WXT 0.21.2 vanilla template, wired to @skilly/browser-core through the same file:
dependency sdk/web uses. Manifest declares the permissions the design calls for,
including the broad <all_urls> host permission — required for pointing into any
page and cross-origin iframes, and flagged in the design's Web Store risk note.

Pins manifestVersion: 3 so Firefox builds MV3 too; WXT defaults that target to MV2,
which the plan's constraints rule out. Both targets build and typecheck clean.

The template's background/content/popup entrypoints are placeholders, replaced in
Tasks 4, 5 and 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two iframes can each independently assign "el_1" to their own first element, so the
model-facing merged digest qualifies every id by frame (f7:el_1) and directives are
un-qualified back to (frameId, localTarget) before routing to a frame's content script.

Pure and DOM-free — no chrome.* dependency — so the collision logic is covered by fast
unit tests instead of a hand-reproduced multi-iframe page.

Two behaviours beyond the plan's draft, both from how frames actually load:
- Merged elements sort by frame id, not Map insertion order. Frames load in arbitrary
  order, so an iframe's content script can register before the top frame's.
- Only the first colon splits the qualifier, since data-skilly ids are author-supplied
  and may contain colons themselves.

Adds types: ["bun-types"] so tsc resolves bun:test; WXT's generated tsconfig sets no
types array, so nothing is clobbered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the active tab's hostname against a bundled skill's URL patterns.

Patterns match the hostname exactly or as a dot-delimited suffix, not as a bare
substring: substring matching would hand "figma.com.attacker.net", "notfigma.com"
and "evil-figma.com" the real Figma skill along with its instructions.

bundledSkills.ts imports skills/figma-basics/SKILL.md raw rather than inlining a
copy, so the extension cannot drift from the canonical skill the Mac app ships.
Verified the bundler actually inlines it (background.js grows by the file's 20KB).
Only Figma has a web app; the other bundled skills are desktop-only and correctly
have no entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every message crossing a chrome.runtime or chrome.tabs boundary is declared once
here — content script, background, offscreen document, popup — so no later task
invents an undocumented shape.

Documents which side of each boundary carries frame-qualified ids ("f7:el_1") and
which carries frame-local ones: the offscreen document speaks qualified ids because
that is what the model emits, and the background un-qualifies before routing into
the owning frame's content script.

Types only; correctness is proven by Tasks 5-8 compiling against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two sources of false-positive silent_failure telemetry that triggered alarms
despite no user-facing breakage:

- studio_mac_token_fetch non-200: the Studio Mac-token endpoint isn't deployed,
  so every push-to-talk 404s and falls back to the worker relay. The fallback
  recovers, so it's not a failure — reporting it flooded the metric. Worker
  relay still logs its own real errors, so a fully broken token path is caught.

- openai_realtime_session response_cancel_not_active / input_audio_buffer_commit_empty:
  these fire on normal use (double-tap cancel, too-short press). Swallow them
  (no telemetry, no UI error); keep reporting every other code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…xecution

MinimalCursorWidget is the second CursorHost (the first is sdk/web's SkillyWidget),
deliberately unshared: no launcher, no bubble, no mic — those mount once in the top
frame, never per-frame.

Three fixes to the planned design, each a real defect:

- Content scripts no longer invent a frameId from WXT's ctx.instanceId. That is a
  random per-injection id, not the browser frame id chrome.tabs.sendMessage routes
  on, so cross-frame pointing would have silently never reached the right frame. The
  background reads sender.frameId instead, which the browser stamps and a page cannot
  forge. Removed frameId from both content->background messages.

- The widget mounts under a data-skilly-widget host. buildDomDigest filters on that
  attribute, so without it Skilly's own cursor and confirm buttons were digested as
  page elements and offered to the model to point at and click.

- A superseded or destroyed confirmation now resolves as declined rather than being
  dropped; the ActionExecutor awaits that promise and would otherwise hang forever.

Uses WXT's cross-browser `browser` global rather than `chrome`, so the content script
is not Chrome-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ting

Coordinator only — it never hosts the Realtime session, since MV3 service workers
have no getUserMedia/WebRTC and can be killed at any time.

Changes from the planned version:

- register-frame is keyed on sender.frameId, and frames whose sender.tab.id is not
  the active tab are dropped. A background tab could otherwise inject elements into
  the active session's digest.
- startSession re-checks activeTabId after each await (network, digest settle,
  offscreen creation). Stopping the session or navigating mid-start would otherwise
  connect an orphaned Realtime session nothing tracks — the same class of bug the
  web SDK's liveSessionGeneration guard exists to prevent.
- Added a tabs.onRemoved handler; closing the tab left activeTabId pointing at a
  dead tab, silently failing every later sendMessage.
- login-start is declared in messages.ts rather than being an ad hoc shape, per the
  plan's own "no undeclared messages" constraint.
- chrome.offscreen is feature-detected. It does not exist on Firefox, so a voice
  session there now shows a clear banner instead of throwing a TypeError.

Auth exchange reports only the HTTP status on failure — the auth code is a
single-use session-granting credential and must not reach a log or error report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hosts the Realtime session outside the service worker, which has no getUserMedia and
can be killed at any time. Chrome-only by nature; the background feature-detects
chrome.offscreen and never creates this on Firefox.

Three lifecycle fixes over the planned version, all the same class of bug the web
SDK's session-teardown rules already document:

- stopSession resolves every in-flight action as session_closed. Dropping the
  resolvers left handleActionToolCall awaiting a promise that could never settle, so
  the model's tool call went unanswered forever.
- handleActionToolCall takes the session it belongs to as an argument and re-checks
  identity before replying. Reading the module-level `session` meant a restart between
  the tool call and its result sent that output to the *new* session.
- start-session on an already-live session now tears the old one down through the
  full stop path rather than just close(), so its usage is reported and its pending
  actions released.

pagehide reports accrued usage if the browser tears the document down mid-session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-out/signed-in views, WorkOS sign-in, and the Start/Stop toggle — the only
session entry point, since Chrome never fires action.onClicked once a default_popup
exists.

Wires skillOverride through to the background. The planned popup wrote it to storage
but nothing read it, so the dropdown would have silently done nothing: selectSkill now
lets it win over URL auto-detection ("" auto-detects, "generic" forces the no-skill
companion, an id pins that skill, an unrecognised id falls back to auto-detect). The
"generic" sentinel lives in skillMatcher.ts so popup and background share one literal.

Also drops the WXT template's leftover demo assets (counter, logos, popup stylesheet).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers what needs no live OpenAI/WorkOS round-trip: the extension loads, the content
script mounts on a real page, the widget marks its own elements so the digest skips
them, and the popup renders signed-out.

The iframe case serves two local origins on different ports so it is genuinely
cross-origin — allFrames injection is the extension's whole reason to exist over the
embeddable widget, and it would fail silently if it regressed.

Scopes `bun test` to tests/ so bun's runner stops trying to execute these Playwright
specs as unit tests, and scopes playwright to tests-e2e/ for the same reason.

Verified: 4 passed. Config (WORKOS_CLIENT_ID, backend URL) and the manual round-trip
remain open — both need WorkOS dashboard access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uses the same WorkOS application Studio's dashboard and the Mac app authenticate
against, not a separate client. WorkOS user ids are scoped to an environment and
mac_entitlements is keyed by user id, so a second environment would hand a paying Mac
subscriber a different id here and report their live subscription as inactive. The
client id is a public identifier; WORKOS_API_KEY stays on the backend, which performs
the code exchange.

Pins manifest.key so the extension id is pfhbjclnbpgaakhkklninbpbedakpdij everywhere.
An unpacked extension's id otherwise derives from its filesystem path, so it varies by
machine and checkout — and the registered WorkOS redirect URI would stop matching. Only
the public half of the key is committed.

Verified the built manifest hashes to that id; 33 unit + 4 e2e pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A subscription lifecycle event with no metadata.user_id previously returned 200
and wrote no entitlement — a paying customer would silently get NO access. Now we
emit a skilly_silent_failure (polar_webhook / missing_user_id_metadata) with the
customer email so it can be reconciled by hand, before ack'ing Polar. Non-lifecycle
events with no user_id still no-op silently as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the extension to parity with the Mac app's flow: the background issues a
single-use state, WorkOS echoes it in the redirect, and a redirect whose state does
not match the one this flow issued is rejected rather than having its code exchanged.

Defence in depth rather than the primary control — launchWebAuthFlow already hands the
response URL only to the extension that opened it — but the Mac surface does this and
the extension silently did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ocument

Offscreen documents are Chrome-only, so Firefox previously got a "not supported in
this browser" banner instead of a voice session. But Firefox MV3 does not need one:
its background is an event page (the built manifest emits background.scripts, versus
Chrome's service_worker), which is a real document with DOM and WebRTC.

Extracts the session logic into src/realtimeHost.ts, shared by both paths. Chrome runs
it inside the offscreen document as before; Firefox runs the same host in-process, with
post() calling the coordinator directly instead of crossing chrome.runtime.
ensureSessionHost/sendToSessionHost hide which one is live.

The extraction also makes the session lifecycle unit-testable for the first time — the
three teardown fixes from the offscreen commit were build-verified only, and now have
real regression tests (pending actions released on stop, an outcome after a restart not
delivered to the new session, malformed tool args refused).

Adds an injectable clock: a session that starts and stops within the same millisecond
correctly reports no usage, which made duration behaviour untestable without sleeping.

43 unit + 4 e2e pass; both targets build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
skilly-studio Ready Ready Preview Aug 1, 2026 10:24pm

Request Review

Merge the latest main branch before shipping the extension so the release retains current appcast entries and the GA Realtime response schema fix while preserving the extension implementation unchanged.

Constraint: PR #46 must not regress the shipping Mac app or web SDK\nRejected: Resolve the appcast conflict with the stale extension-side feed | would drop the current 2.3 and 2.4 updates\nConfidence: high\nScope-risk: moderate\nDirective: Keep extension work additive to the current Mac and @skilly/web behavior\nTested: git diff --check\nNot-tested: Full cross-surface regression suite runs after this merge commit
Merge the latest web-actions branch into the release integration so extension work cannot reintroduce stale Mac session handling, Worker telemetry behavior, update settings, or token lifetime assumptions.

Constraint: The extension release must preserve the currently deployed Mac and Worker behavior\nRejected: Release PR #46 from its older fork point | it would omit subsequent cross-surface hardening\nConfidence: high\nScope-risk: moderate\nDirective: Validate Mac, Worker, Studio, browser-core, web SDK, and extension together before merging\nTested: conflict-free Git merge\nNot-tested: Full regression matrix follows this integration commit
The Debug application was renamed to Skilly Dev for safe parallel installation, but the test target still searched for Skilly.app and imported a module that the rename had changed to Skilly_Dev. Point the Debug TEST_HOST at Skilly Dev and pin its internal Swift module to Skilly; Release remains attached to the shipping Skilly product.

Constraint: Debug and production apps must remain side-by-side without disabling unit tests\nRejected: Rename the Debug product back to Skilly | would undo the isolation the dev-build refactor introduced\nConfidence: high\nScope-risk: narrow\nDirective: Keep the test host executable path and Debug PRODUCT_MODULE_NAME aligned if the Dev product name changes\nTested: Xcode Debug host build succeeded; test bundle now reaches Swift module compilation\nNot-tested: Full unit suite rerun follows this commit
The restored native suite was asserting an older six-stage Blender curriculum, the frontmatter description instead of the documented non-empty preamble, an obsolete validation-message word, and a budget fixture that mathematically fit all entries. Align those assertions and fixture sizes with current production behavior without changing runtime code.

Constraint: Tests must protect current behavior instead of pinning superseded bundled content\nRejected: Change production parsing or vocabulary trimming to satisfy stale assertions | would regress documented behavior\nConfidence: high\nScope-risk: narrow\nDirective: Update bundled-skill fixture expectations when curriculum content intentionally changes\nTested: xcodebuild test, 83 tests across 14 suites passed\nNot-tested: UI automation requiring macOS permissions
Generate target-specific manifests so Firefox does not inherit Chromium-only capabilities, and replace the cursor widget's static HTML injection with typed DOM construction.

Constraint: Firefox MV3 requires a stable Gecko id and explicit data-collection declarations.

Rejected: Keep one shared manifest | Firefox lint rejects Chrome offscreen usage and missing disclosures.

Confidence: high

Scope-risk: narrow

Directive: Keep store permissions target-specific when adding browser capabilities.

Tested: 43 extension unit tests, 4 Playwright scenarios, Chrome and Firefox MV3 builds, Firefox web-ext lint with zero warnings
Run the existing browser package, extension, and Mac unit suites in pull requests so shared-core refactors cannot silently break the shipped web or desktop surfaces.

Constraint: Reuse repository lockfiles and existing test commands without adding dependencies.

Rejected: Rely on local verification only | future pull requests would retain the same blind spot.

Confidence: high

Scope-risk: narrow

Directive: Keep browser package and Mac unit checks required when shared contracts change.

Tested: Workflow YAML parsed locally; all referenced commands passed on the integrated branch
Limit push-triggered CI to main so feature branches use their pull-request run instead of consuming duplicate runners.

Confidence: high

Scope-risk: narrow

Tested: Workflow YAML parsed locally
Inject the token route dependencies through a testable handler instead of replacing the shared OpenAI module process-wide, and invoke the extension's bounded unit-test script in CI.

Constraint: Next App Router route files may only export recognized route fields and handlers.

Rejected: Retain Bun module mocks | their process-wide replacement makes unrelated web tests order-dependent.

Confidence: high

Scope-risk: narrow

Directive: Test shared backend dependencies by injection, not process-global module replacement.

Tested: backend 126 tests, typecheck, production build; extension 43 tests, typecheck, Chrome and Firefox builds
Run the headed MV3 Playwright context under Xvfb because extension loading requires a headed Chromium session.

Constraint: Playwright cannot load this MV3 extension in its ordinary headless context.

Confidence: high

Scope-risk: narrow

Tested: Workflow YAML parsed; the same four Playwright scenarios pass locally
@engmsaleh
engmsaleh merged commit a969d24 into main Aug 1, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant