Skilly browser extension: shared browser-core, /api/extension/* routes, and the WXT extension shell - #46
Merged
Merged
Conversation
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>
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three plans, landed end to end.
Plan 1 —
sdk/browser-core: digest / pointing / actions / prompt / realtime extracted out ofsdk/webinto a package shared with the extension;PointingEnginedecoupled from the concrete widget viaCursorHost.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 reusesmac_entitlementskeyed 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
tscclean across all packages; both browser targets buildactive, a real Realtime secret minted, usage row stored — with code-replay, forged-token and no-auth all 401Notable fixes beyond the plans
sender.frameId, not WXT'sctx.instanceId(a random per-injection id, so pointing into iframes would silently never have worked)figma.com.attacker.netthe real skilldata-skilly-widgetso it is excluded from its own digeststartSessionre-checks the active tab after every awaitBefore this can serve traffic
Production is missing
SESSION_TOKEN_SECRETand an extension OpenAI key (OPENAI_API_KEY_EXTENSIONorOPENAI_API_KEY). Without them/api/extension/auth/exchangethrows and/openai/tokenreturns 500.Firefox voice is architecturally unblocked but unverified at runtime (needs a real browser + microphone).
🤖 Generated with Claude Code