From a3263ea5163469cca74a60ef359a042f57b61bbc Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:49:41 -0700 Subject: [PATCH 1/8] feat(onboarding): record the OS on the first Bring your own open The BYO page passes the OS it detected (?os=mac|linux|windows|other) on its load request, and markByoOpened writes it as onboarding.byoOpenedOs in the same write-once update as byoOpenedAt. Any other value records the visit without an OS. The listener only runs on a Mac today, so this splits "looked and left" by whether setup could have worked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- backend/__tests__/unit/routes/hosted.test.js | 7 +++- .../unit/services/onboardingMarks.test.js | 22 ++++++++++ backend/models/User.ts | 3 ++ backend/routes/hosted.ts | 5 ++- backend/services/onboardingMarks.ts | 24 ++++++++++- .../v2/__tests__/V2AgentBYOHosted.test.tsx | 42 ++++++++++++++++++- frontend/src/v2/components/V2AgentBYO.tsx | 17 +++++++- 7 files changed, 112 insertions(+), 8 deletions(-) diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index 7b83e74ea7..68e3c05fdc 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -183,7 +183,12 @@ describe('/api/hosted', () => { const res = await request(app).get('/api/hosted/availability'); expect(res.status).toBe(200); expect(mockMarkByoOpened).toHaveBeenCalledTimes(1); - expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1'); + expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1', undefined); + }); + + it('passes the OS the page detected to the first-visit mark', async () => { + await request(app).get('/api/hosted/availability?os=linux'); + expect(mockMarkByoOpened).toHaveBeenCalledWith('owner-1', 'linux'); }); it('checks new-pod hosted availability without marking Bring your own opened', async () => { diff --git a/backend/__tests__/unit/services/onboardingMarks.test.js b/backend/__tests__/unit/services/onboardingMarks.test.js index 1cc7f57bd9..0922a58382 100644 --- a/backend/__tests__/unit/services/onboardingMarks.test.js +++ b/backend/__tests__/unit/services/onboardingMarks.test.js @@ -139,6 +139,28 @@ describe('markByoOpened', () => { expect(update.$set['onboarding.byoOpenedAt']).toBeInstanceOf(Date); }); + test('records the OS the page detected in the same write-once update', () => { + markByoOpened('u1', 'windows'); + const [filter, update] = mockUpdateOne.mock.calls[0]; + expect(filter).toEqual({ _id: 'u1', isBot: { $ne: true }, 'onboarding.byoOpenedAt': { $exists: false } }); + expect(Object.keys(update.$set)).toEqual(['onboarding.byoOpenedAt', 'onboarding.byoOpenedOs']); + expect(update.$set['onboarding.byoOpenedOs']).toBe('windows'); + }); + + test.each(['mac', 'linux', 'windows', 'other'])('keeps os=%s', (os) => { + markByoOpened('u1', os); + expect(mockUpdateOne.mock.calls[0][1].$set['onboarding.byoOpenedOs']).toBe(os); + }); + + test.each([['ios'], ['Mac'], [''], [['mac']], [{ os: 'mac' }], [42]])( + 'any other os value (%p) records the visit without an OS', + (os) => { + markByoOpened('u1', os); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + expect(Object.keys(mockUpdateOne.mock.calls[0][1].$set)).toEqual(['onboarding.byoOpenedAt']); + }, + ); + test('no person, no write', () => { markByoOpened(''); markByoOpened(undefined); diff --git a/backend/models/User.ts b/backend/models/User.ts index 3be3e3ba82..1e4b8534f9 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -210,6 +210,8 @@ export interface IUser extends Document { firstStartId?: string; firstStartAt?: Date; byoOpenedAt?: Date; + /** OS the page detected on that first open, written with byoOpenedAt. */ + byoOpenedOs?: 'mac' | 'linux' | 'windows' | 'other'; /** First BYO setup command copied in the browser, never overwritten. */ byoCommandCopiedAt?: Date; /** Fixed kind only; command contents and tokens are never stored. */ @@ -460,6 +462,7 @@ const userSchema = new Schema({ firstStartId: { type: String }, firstStartAt: { type: Date }, byoOpenedAt: { type: Date }, + byoOpenedOs: { type: String, enum: ['mac', 'linux', 'windows', 'other'] }, byoCommandCopiedAt: { type: Date }, byoCommandType: { type: String, enum: ['mcp', 'cli'] }, byoFirstConnectionAttemptAt: { type: Date }, diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index e36b1163a5..fe1775f8e2 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -191,10 +191,11 @@ const relayWorkerError = (res: express.Response, error: any) => { * * Only the Bring your own page calls this, on load, so it also records the * person's first visit to that page (write-once, fire-and-forget; - * services/onboardingMarks.ts). + * services/onboardingMarks.ts). `?os=` is the OS the page detected; the mark + * keeps only mac, linux, windows or other and records no OS for anything else. */ router.get('/availability', hostedRateLimit, auth, async (req: any, res: any) => { - markByoOpened(getUserId(req)); + markByoOpened(getUserId(req), req.query?.os); const engine = engineForNewHostedInstall(); // The caller's own plan line (TASK-268), so the page states the limit they get. const caps = await hostedRuntime.hostedCapsForOwner(getUserId(req)); diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index 81b0f739a4..dae63cb5a3 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -25,6 +25,11 @@ * read from the page's own load request (GET /api/hosted/availability, which * nothing else calls). It tells "never looked at setup" apart from "looked and * left". + * + * byoOpenedOs is written with it, in the same write-once update: the OS the + * page detected from the browser, one of BYO_OPENED_OS. The listener only runs + * on a Mac today, so this splits "left" by whether setup could have worked at + * all. An unknown or missing value records the visit without an OS. */ import User from '../models/User'; import { SCOUT_STARTS } from '../config/native-agents/scout'; @@ -92,15 +97,30 @@ function recordFirstStart(id: string, startId: string): void { } } -export function markByoOpened(userId: unknown): void { +export const BYO_OPENED_OS = ['mac', 'linux', 'windows', 'other'] as const; +export type ByoOpenedOs = typeof BYO_OPENED_OS[number]; + +export const parseByoOpenedOs = (value: unknown): ByoOpenedOs | undefined => ( + typeof value === 'string' && (BYO_OPENED_OS as readonly string[]).includes(value) + ? value as ByoOpenedOs + : undefined +); + +export function markByoOpened(userId: unknown, os?: unknown): void { const id = String(userId || ''); if (!id) return; if (byoOpenedMarked.has(id)) return; byoOpenedMarked.add(id); + const detectedOs = parseByoOpenedOs(os); try { User.updateOne( { _id: id, isBot: { $ne: true }, 'onboarding.byoOpenedAt': { $exists: false } }, - { $set: { 'onboarding.byoOpenedAt': new Date() } }, + { + $set: { + 'onboarding.byoOpenedAt': new Date(), + ...(detectedOs ? { 'onboarding.byoOpenedOs': detectedOs } : {}), + }, + }, ).catch(() => { byoOpenedMarked.delete(id); }); diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index 45220a6f4d..d1bebcaa04 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -176,7 +176,7 @@ describe('V2AgentBYO — Run it here', () => { ); await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toBeInTheDocument()); // let the availability effect settle, then assert it did not stomp the mode - await waitFor(() => expect(axios.get.mock.calls.some(([url]) => url === '/api/hosted/availability')).toBe(true)); + await waitFor(() => expect(axios.get.mock.calls.some(([url]) => url.startsWith('/api/hosted/availability'))).toBe(true)); await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toHaveAttribute('aria-pressed', 'true')); expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'false'); window.history.pushState({}, '', '/'); @@ -447,3 +447,43 @@ describe('V2AgentBYO — Run it here', () => { expect(axios.post.mock.calls[0][1]).toMatchObject({ config: { runtime: { runtimeType: 'webhook' } } }); }); }); + +// The page's load request carries the OS it detected, for the server's +// first-open mark (User.onboarding.byoOpenedOs). Unlike the setup tabs, an +// OS it can't place is reported as 'other', never as a Mac. +describe('V2AgentBYO — detected OS on the availability request', () => { + const availabilityUrl = () => axios.get.mock.calls + .map(([url]) => url) + .find((url) => url.startsWith('/api/hosted/availability')); + + const withPlatform = async (platform, userAgent) => { + const uaData = Object.getOwnPropertyDescriptor(window.navigator, 'userAgentData'); + const ua = jest.spyOn(window.navigator, 'userAgent', 'get'); + if (userAgent) ua.mockReturnValue(userAgent); + Object.defineProperty(window.navigator, 'userAgentData', { + value: platform === undefined ? undefined : { platform }, configurable: true, + }); + try { + mockGet({ configured: true }); + renderPage(); + await waitFor(() => expect(availabilityUrl()).toBeDefined()); + return availabilityUrl(); + } finally { + ua.mockRestore(); + if (uaData) Object.defineProperty(window.navigator, 'userAgentData', uaData); + else delete window.navigator.userAgentData; + } + }; + + test.each([ + ['macOS', undefined, 'mac'], + ['Windows', undefined, 'windows'], + ['Linux', undefined, 'linux'], + ['Android', undefined, 'other'], + [undefined, 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', 'other'], + [undefined, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', 'mac'], + [undefined, 'Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/20.0.3', 'other'], + ])('platform %p / UA %p reports os=%s', async (platform, userAgent, os) => { + expect(await withPlatform(platform, userAgent)).toBe(`/api/hosted/availability?os=${os}`); + }); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 74c341b0f3..a70bb6327e 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -90,6 +90,18 @@ const browserOs = (): SetupOs => { if (/X11|CrOS|Linux (x86_64|aarch64|armv|i686)|^Linux$/.test(platform) && !/Android/.test(platform)) return 'linux'; return 'mac'; }; +// What the page saw, for the first-open mark only (byoOpenedOs). Unlike +// browserOs it does not default to mac: a browser it can't place is 'other'. +type OpenedOs = SetupOs | 'other'; +const openedOs = (): OpenedOs => { + if (typeof navigator === 'undefined') return 'other'; + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = nav.userAgentData?.platform || nav.userAgent || ''; + if (/iPhone|iPad|Android/.test(platform)) return 'other'; + if (/Mac|macOS/.test(platform)) return 'mac'; + const os = browserOs(); + return os === 'mac' ? 'other' : os; +}; const SETUP_OS_OPTIONS: Array<{ os: SetupOs; labelKey: string }> = [ { os: 'mac', labelKey: 'agentByo.listen.osMac' }, { os: 'linux', labelKey: 'agentByo.listen.osLinux' }, @@ -492,8 +504,9 @@ const V2AgentBYO: React.FC = () => { } try { // This request also records the person's first visit to this page on - // the server (User.onboarding.byoOpenedAt). Keep it on page load. - const availability = await api.get('/api/hosted/availability'); + // the server (User.onboarding.byoOpenedAt, and byoOpenedOs from ?os). + // Keep it on page load. + const availability = await api.get(`/api/hosted/availability?os=${openedOs()}`); if (cancelled) return; if (availability && typeof availability.configured === 'boolean') { // Sets no mode. This effect re-runs on every pod change (podId is a From f311e37ead22d660b9bc3968bb0ce4e3a2ef88b7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:52:27 -0700 Subject: [PATCH 2/8] fix(onboarding): an iPad in desktop mode is 'other'; round-trip byoOpenedOs CodeRabbit on #2903: iPadOS Safari in desktop mode sends a Mac user agent, so a Mac-like platform with touch points is now 'other'. Adds a service-tier test that byoOpenedOs round-trips through the real User schema, an unknown value stores no OS, and a later open never overwrites the first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../service/onboardingByoOpenedOs.test.js | 59 +++++++++++++++++++ .../v2/__tests__/V2AgentBYOHosted.test.tsx | 11 +++- frontend/src/v2/components/V2AgentBYO.tsx | 3 +- 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/service/onboardingByoOpenedOs.test.js diff --git a/backend/__tests__/service/onboardingByoOpenedOs.test.js b/backend/__tests__/service/onboardingByoOpenedOs.test.js new file mode 100644 index 0000000000..29618e2b3d --- /dev/null +++ b/backend/__tests__/service/onboardingByoOpenedOs.test.js @@ -0,0 +1,59 @@ +/** + * byoOpenedOs round-trips through the real User schema: the enum admits the + * four values markByoOpened writes, and the mark's write-once filter keeps the + * first open's OS. The unit test (unit/services/onboardingMarks.test.js) pins + * the update payload; this pins that the schema stores it. + */ +const User = require('../../models/User'); +const { markByoOpened, resetOnboardingMarksForTest } = require('../../services/onboardingMarks'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); + +const makeUser = (name) => User.create({ + username: name, email: `${name}@example.test`, password: 'Opened-os-123!', +}); + +const settle = async (id, predicate) => { + for (let i = 0; i < 50; i += 1) { + // eslint-disable-next-line no-await-in-loop + const row = await User.findById(id).lean(); + if (predicate(row)) return row; + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, 20)); + } + return User.findById(id).lean(); +}; + +beforeAll(setupMongoDb); +afterAll(closeMongoDb); +beforeEach(async () => { + await clearMongoDb(); + resetOnboardingMarksForTest(); +}); + +test.each(['mac', 'linux', 'windows', 'other'])('stores os=%s with the first-open time', async (os) => { + const user = await makeUser(`opened-${os}`); + markByoOpened(String(user._id), os); + const row = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt); + expect(row.onboarding.byoOpenedAt).toBeInstanceOf(Date); + expect(row.onboarding.byoOpenedOs).toBe(os); +}); + +test('an unknown value records the visit without an OS', async () => { + const user = await makeUser('opened-ios'); + markByoOpened(String(user._id), 'ios'); + const row = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt); + expect(row.onboarding.byoOpenedAt).toBeInstanceOf(Date); + expect(row.onboarding).not.toHaveProperty('byoOpenedOs'); +}); + +test('a later open from another OS does not overwrite the first', async () => { + const user = await makeUser('opened-twice'); + markByoOpened(String(user._id), 'windows'); + const first = await settle(user._id, (r) => r?.onboarding?.byoOpenedAt); + resetOnboardingMarksForTest(); // as after a restart + markByoOpened(String(user._id), 'mac'); + await new Promise((r) => setTimeout(r, 200)); + const row = await User.findById(user._id).lean(); + expect(row.onboarding.byoOpenedOs).toBe('windows'); + expect(row.onboarding.byoOpenedAt).toEqual(first.onboarding.byoOpenedAt); +}); diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index d1bebcaa04..13a16ff2d8 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -456,10 +456,12 @@ describe('V2AgentBYO — detected OS on the availability request', () => { .map(([url]) => url) .find((url) => url.startsWith('/api/hosted/availability')); - const withPlatform = async (platform, userAgent) => { + const withPlatform = async (platform, userAgent, touchPoints = 0) => { const uaData = Object.getOwnPropertyDescriptor(window.navigator, 'userAgentData'); const ua = jest.spyOn(window.navigator, 'userAgent', 'get'); if (userAgent) ua.mockReturnValue(userAgent); + const touch = Object.getOwnPropertyDescriptor(window.navigator, 'maxTouchPoints'); + Object.defineProperty(window.navigator, 'maxTouchPoints', { value: touchPoints, configurable: true }); Object.defineProperty(window.navigator, 'userAgentData', { value: platform === undefined ? undefined : { platform }, configurable: true, }); @@ -470,6 +472,8 @@ describe('V2AgentBYO — detected OS on the availability request', () => { return availabilityUrl(); } finally { ua.mockRestore(); + if (touch) Object.defineProperty(window.navigator, 'maxTouchPoints', touch); + else delete window.navigator.maxTouchPoints; if (uaData) Object.defineProperty(window.navigator, 'userAgentData', uaData); else delete window.navigator.userAgentData; } @@ -486,4 +490,9 @@ describe('V2AgentBYO — detected OS on the availability request', () => { ])('platform %p / UA %p reports os=%s', async (platform, userAgent, os) => { expect(await withPlatform(platform, userAgent)).toBe(`/api/hosted/availability?os=${os}`); }); + + test('an iPad in desktop mode (Mac UA, touch) reports os=other', async () => { + expect(await withPlatform(undefined, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', 5)) + .toBe('/api/hosted/availability?os=other'); + }); }); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index a70bb6327e..f5f3d2443b 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -98,7 +98,8 @@ const openedOs = (): OpenedOs => { const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; const platform = nav.userAgentData?.platform || nav.userAgent || ''; if (/iPhone|iPad|Android/.test(platform)) return 'other'; - if (/Mac|macOS/.test(platform)) return 'mac'; + // iPadOS Safari in desktop mode reports a Mac user agent; touch gives it away. + if (/Mac|macOS/.test(platform)) return (nav.maxTouchPoints || 0) > 1 ? 'other' : 'mac'; const os = browserOs(); return os === 'mac' ? 'other' : os; }; From 9b85bac655b60679c16dcec0b78a21d84974c37f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:51:44 -0700 Subject: [PATCH 3/8] fix(onboarding): the stalled nudge names the OS only when it knows it The copied-command nudge said "here is the one thing to check for your OS" with no OS in hand. It now reads byoOpenedOs (#2903): on Linux or Windows a copied listener command gets the page's own unavailable sentence (the listener answers @mentions only on a Mac for now; choose Run it here, or run it on a Mac). On mac, other or unknown it drops "for your OS" and keeps the neutral check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../services/stalledConnectService.test.js | 50 ++++++++++++++++++- backend/services/stalledConnectCopy.ts | 25 +++++++++- backend/services/stalledConnectService.ts | 2 + 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/stalledConnectService.test.js b/backend/__tests__/unit/services/stalledConnectService.test.js index 59fb98eccb..52cdfa3cb4 100644 --- a/backend/__tests__/unit/services/stalledConnectService.test.js +++ b/backend/__tests__/unit/services/stalledConnectService.test.js @@ -144,7 +144,55 @@ describe('stalledConnectService.scan', () => { 'copied the listener command a few minutes after you created the seat', ); expect(posted.content).toContain('no connection arrived'); - expect(posted.content).toContain('one thing to check for your OS'); + // Nothing here knows the OS, so the copy doesn't claim to. + expect(posted.content).toContain('here is the one thing to check: run `commonly agent run ai1-agent`'); + expect(posted.content).not.toContain('for your OS'); + }); + + const copiedListenerOn = (byoOpenedOs, byoCommandType = 'cli') => mockUserFindById.mockReturnValue({ + select: () => ({ + lean: () => Promise.resolve({ + username: 'alice', + onboarding: { + byoCommandCopiedAt: new Date(OLD_TOKEN.getTime() + 8 * MINUTE), + byoCommandType, + ...(byoOpenedOs ? { byoOpenedOs } : {}), + }, + }), + }), + }); + + it.each([['linux', 'Linux'], ['windows', 'Windows']])( + 'on %s, says the listener answers @mentions only on a Mac and offers Run it here', + async (os, label) => { + setup(); + copiedListenerOn(os); + await scan({ now: NOW }); + const { content } = mockPost.mock.calls[0][0]; + expect(content).toContain(`The listener can't answer @mentions from ${label} yet`); + expect(content).toContain('Choose “Run it here” on the Bring your own page for a hosted agent'); + expect(content).toContain('or run `commonly agent run ai1-agent` on a Mac'); + expect(content).toContain("I'll post here the moment I connect"); + expect(content).not.toContain('one thing to check'); + }, + ); + + it.each(['mac', 'other', undefined])('with os=%p keeps the neutral check', async (os) => { + setup(); + copiedListenerOn(os); + await scan({ now: NOW }); + const { content } = mockPost.mock.calls[0][0]; + expect(content).toContain('here is the one thing to check: run'); + expect(content).not.toContain("can't answer @mentions"); + }); + + it('an MCP copy on Linux is not told about the listener', async () => { + setup(); + copiedListenerOn('linux', 'mcp'); + await scan({ now: NOW }); + const { content } = mockPost.mock.calls[0][0]; + expect(content).not.toContain("listener can't answer"); + expect(content).toContain('MCP-only clients can call Commonly'); }); it('explains that an MCP-only client cannot answer mentions', async () => { diff --git a/backend/services/stalledConnectCopy.ts b/backend/services/stalledConnectCopy.ts index 9376cba05a..1eaab7d9f1 100644 --- a/backend/services/stalledConnectCopy.ts +++ b/backend/services/stalledConnectCopy.ts @@ -44,8 +44,20 @@ export type StalledConnectCopyEvidence = { commandType?: 'mcp' | 'cli' | null; /** Token issue time for the current stalled episode. */ tokenIssuedAt?: Date | string | null; + /** OS the Bring your own page detected on the first open (byoOpenedOs). */ + openedOs?: 'mac' | 'linux' | 'windows' | 'other' | null; }; +/** + * The listener answers @mentions only on a Mac for now: Commonly runs Claude + * Code and Codex in a sandbox, and neither sandbox runs them on Linux or + * Windows (V2AgentBYO's listenerRunsOn). The page says so with + * agentByo.listen.unavailableClaude / unavailableCodex; this is the same + * sentence after the fact. The server never learns which adapter the copied + * command named, so the copy names the listener, not the CLI. + */ +const LISTENER_UNAVAILABLE_OS: Record = { linux: 'Linux', windows: 'Windows' }; + const copiedAtOffsetSeconds = (evidence: StalledConnectCopyEvidence): number | null => { if (!evidence.commandCopiedAt || !evidence.tokenIssuedAt) return null; const copiedAt = evidence.commandCopiedAt instanceof Date @@ -82,9 +94,20 @@ export const composeStalledConnectNudge = ({ const copiedThing = evidence.commandType === 'mcp' ? 'MCP command' : evidence.commandType === 'cli' ? 'listener command' : 'command'; + const unavailableOs = evidence.commandType === 'cli' && evidence.openedOs + ? LISTENER_UNAVAILABLE_OS[evidence.openedOs] + : undefined; + if (unavailableOs) { + return `@${installerName} — you copied the listener command ` + + `${copiedAtDescription(offsetSeconds)}; no connection arrived. ` + + `The listener can't answer @mentions from ${unavailableOs} yet: Commonly runs it in a sandbox, ` + + `and on ${unavailableOs} that sandbox can't run it. ` + + `Choose “Run it here” on the Bring your own page for a hosted agent, or run \`${fixCommand}\` on a Mac. ` + + `I'll post here the moment I connect.`; + } return `@${installerName} — you copied the ${copiedThing} ` + `${copiedAtDescription(offsetSeconds)}; ` - + `no connection arrived — here is the one thing to check for your OS: ` + + `no connection arrived — here is the one thing to check: ` + `run \`${fixCommand}\` on the machine where I should live.` + (evidence.commandType === 'mcp' ? ' MCP-only clients can call Commonly, but they do not answer @mentions.' diff --git a/backend/services/stalledConnectService.ts b/backend/services/stalledConnectService.ts index 735d24ffeb..c5ebd0ae80 100644 --- a/backend/services/stalledConnectService.ts +++ b/backend/services/stalledConnectService.ts @@ -239,6 +239,7 @@ const nudgeOnce = async ({ onboarding?: { byoCommandCopiedAt?: Date; byoCommandType?: 'mcp' | 'cli'; + byoOpenedOs?: 'mac' | 'linux' | 'windows' | 'other'; }; } | null; if (!installer?.username) return; // Nobody to address; a nameless nudge is noise. @@ -284,6 +285,7 @@ const nudgeOnce = async ({ evidence: { commandCopiedAt: installer.onboarding?.byoCommandCopiedAt, commandType: installer.onboarding?.byoCommandType, + openedOs: installer.onboarding?.byoOpenedOs, tokenIssuedAt: issuedAt, }, }); From 6b5f9acc394652abdeb1c05f524d979fc3a2a2ae Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:52:50 -0700 Subject: [PATCH 4/8] fix(onboarding): the Linux/Windows nudge is conditional on where it ran CodeRabbit on #2904: byoOpenedOs is the browser's OS, and the page shows the listener command only on its Mac tab, so a copy from a Linux browser may have been run on a Mac. The sentence now reads "If you ran it on Linux, that's why", then offers the Mac run or Run it here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../unit/services/stalledConnectService.test.js | 10 ++++++---- backend/services/stalledConnectCopy.ts | 12 +++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/__tests__/unit/services/stalledConnectService.test.js b/backend/__tests__/unit/services/stalledConnectService.test.js index 52cdfa3cb4..c9f385e2fa 100644 --- a/backend/__tests__/unit/services/stalledConnectService.test.js +++ b/backend/__tests__/unit/services/stalledConnectService.test.js @@ -163,15 +163,17 @@ describe('stalledConnectService.scan', () => { }); it.each([['linux', 'Linux'], ['windows', 'Windows']])( - 'on %s, says the listener answers @mentions only on a Mac and offers Run it here', + 'from a %s browser, says the listener answers @mentions only on a Mac, if it ran there', async (os, label) => { setup(); copiedListenerOn(os); await scan({ now: NOW }); const { content } = mockPost.mock.calls[0][0]; - expect(content).toContain(`The listener can't answer @mentions from ${label} yet`); - expect(content).toContain('Choose “Run it here” on the Bring your own page for a hosted agent'); - expect(content).toContain('or run `commonly agent run ai1-agent` on a Mac'); + // Conditional: the page shows this command only on its Mac tab, so the + // browser's OS is not proof of where it ran. + expect(content).toContain(`If you ran it on ${label}, that's why: the listener can't answer @mentions from ${label} yet`); + expect(content).toContain('Run `commonly agent run ai1-agent` on a Mac'); + expect(content).toContain('or choose “Run it here” on the Bring your own page for a hosted agent'); expect(content).toContain("I'll post here the moment I connect"); expect(content).not.toContain('one thing to check'); }, diff --git a/backend/services/stalledConnectCopy.ts b/backend/services/stalledConnectCopy.ts index 1eaab7d9f1..ffd7e50a7f 100644 --- a/backend/services/stalledConnectCopy.ts +++ b/backend/services/stalledConnectCopy.ts @@ -55,6 +55,12 @@ export type StalledConnectCopyEvidence = { * agentByo.listen.unavailableClaude / unavailableCodex; this is the same * sentence after the fact. The server never learns which adapter the copied * command named, so the copy names the listener, not the CLI. + * + * It hedges, unlike the rest of this file. The page shows the listener command + * only on its Mac tab, so a copy from a Linux or Windows browser means the + * person picked Mac there: they may have run it on a Mac, or on the machine + * they were browsing from. byoOpenedOs is the browser's OS, not where the + * command ran, so the sentence is conditional on it. */ const LISTENER_UNAVAILABLE_OS: Record = { linux: 'Linux', windows: 'Windows' }; @@ -100,9 +106,9 @@ export const composeStalledConnectNudge = ({ if (unavailableOs) { return `@${installerName} — you copied the listener command ` + `${copiedAtDescription(offsetSeconds)}; no connection arrived. ` - + `The listener can't answer @mentions from ${unavailableOs} yet: Commonly runs it in a sandbox, ` - + `and on ${unavailableOs} that sandbox can't run it. ` - + `Choose “Run it here” on the Bring your own page for a hosted agent, or run \`${fixCommand}\` on a Mac. ` + + `If you ran it on ${unavailableOs}, that's why: the listener can't answer @mentions from ` + + `${unavailableOs} yet, because Commonly runs it in a sandbox that can't run there. ` + + `Run \`${fixCommand}\` on a Mac, or choose “Run it here” on the Bring your own page for a hosted agent. ` + `I'll post here the moment I connect.`; } return `@${installerName} — you copied the ${copiedThing} ` From 424e490e621188553fee7ceebe31bd7c5e6a3972 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:41:54 -0700 Subject: [PATCH 5/8] docs(onboarding): name Mac as the only listener OS (#2903) The listener runs only on Mac: listenerRunsOn in V2AgentBYO.tsx is os === 'mac'. The comment said Mac and Linux, which described the wrong gate and would mislead the reader of byoOpenedOs. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- backend/services/onboardingMarks.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index f9f8ce5ea2..dde3d197a7 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -28,8 +28,9 @@ * * byoOpenedOs is written with it, in the same write-once update: the OS the * page detected from the browser, one of BYO_OPENED_OS. The listener runs only - * on Mac and Linux today, so this splits "left" by whether setup could have worked at - * all. An unknown or missing value records the visit without an OS. + * on Mac (listenerRunsOn in V2AgentBYO.tsx is os === 'mac'), so this splits + * "left" by whether setup could have worked at all. An unknown or missing value + * records the visit without an OS. * * Three surface marks come from the browser through POST /api/stats/byo-step, * as fixed step names (D139): inviteCardShownAt and inviteCardDismissedAt for From 5dc7729734074097a0dcf3d6139a5ecbf74d606f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:37:47 -0700 Subject: [PATCH 6/8] docs(onboarding): byoOpenedOs in the signup-stall runbook; the split is about the listener path (#2903) Co-Authored-By: Claude Opus 5.5 Co-authored-by: Ember Agent-Model: claude/claude-opus-5-5/medium --- backend/services/onboardingMarks.ts | 2 +- docs/runbooks/new-signup-stall.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index f38c8d9f2e..a3e6d9236f 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -29,7 +29,7 @@ * byoOpenedOs is written with it, in the same write-once update: the OS the * page detected from the browser, one of BYO_OPENED_OS. The listener runs only * on Mac (listenerRunsOn in V2AgentBYO.tsx is os === 'mac'), so this splits - * "left" by whether setup could have worked at all. An unknown or missing value + * "left" by whether the listener path could have worked. An unknown or missing value * records the visit without an OS. * * Three surface marks come from the browser through POST /api/stats/byo-step, diff --git a/docs/runbooks/new-signup-stall.md b/docs/runbooks/new-signup-stall.md index 9b21116288..c71a62803e 100644 --- a/docs/runbooks/new-signup-stall.md +++ b/docs/runbooks/new-signup-stall.md @@ -25,6 +25,7 @@ confirm, for example against `lastActive` or whether they sent a message | `podOpenedAt` | the first authorized first-page fetch (no `before` cursor) of the messages of any pod they created; for a new signup that is normally My Workspace. It is set before the messages are read, so it records the request, not a render | their client asked for a pod's messages; for a new signup, very likely the welcome | | `firstStartAt` + `firstStartId` | their first message in a pod they created matches a start's label, or the server resolves a click on their own "Run an agent here" | they acted, and which choice | | `byoOpenedAt` | they first load Bring your own (`GET /api/hosted/availability`) | they looked at setup | +| `byoOpenedOs` | written with `byoOpenedAt`, from the `?os=` the page sends: `mac`, `linux`, `windows` or `other`. Unset for anyone who opened the page before it shipped, and for an unknown value | whether the listener path (Mac only) could have worked for them | Read them together with whether the person sent a message in their workspace, and when (timestamps and ids). From 3a1339aab1aae63bb979c45f05216566519995e8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:38:00 -0700 Subject: [PATCH 7/8] docs(onboarding): re-wrap the byoOpenedOs comment Co-Authored-By: Claude Opus 5.5 Co-authored-by: Ember Agent-Model: claude/claude-opus-5-5/medium --- backend/services/onboardingMarks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index a3e6d9236f..62db8ffb75 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -29,8 +29,8 @@ * byoOpenedOs is written with it, in the same write-once update: the OS the * page detected from the browser, one of BYO_OPENED_OS. The listener runs only * on Mac (listenerRunsOn in V2AgentBYO.tsx is os === 'mac'), so this splits - * "left" by whether the listener path could have worked. An unknown or missing value - * records the visit without an OS. + * "left" by whether the listener path could have worked. An unknown or + * missing value records the visit without an OS. * * Three surface marks come from the browser through POST /api/stats/byo-step, * as fixed step names: inviteCardShownAt and inviteCardDismissedAt for From 5f6774d88aef72d7c49fde20d4e68e7a0f1ec85f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:40:48 -0700 Subject: [PATCH 8/8] fix(onboarding): the Linux/Windows nudge states only what holds for Claude Code and Codex (#2904) The sandbox cause is true for Claude Code only; Codex on Linux and Windows is untested, not refused. The server can't tell which adapter was copied, so the copy now says just that the listener answers only from a Mac. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Ember Agent-Model: claude/claude-opus-5-5/medium --- .../unit/services/stalledConnectService.test.js | 4 +++- backend/services/stalledConnectCopy.ts | 16 ++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/backend/__tests__/unit/services/stalledConnectService.test.js b/backend/__tests__/unit/services/stalledConnectService.test.js index f053087edd..921610ca98 100644 --- a/backend/__tests__/unit/services/stalledConnectService.test.js +++ b/backend/__tests__/unit/services/stalledConnectService.test.js @@ -171,7 +171,9 @@ describe('stalledConnectService.scan', () => { const { content } = mockPost.mock.calls[0][0]; // Conditional: the page shows this command only on its Mac tab, so the // browser's OS is not proof of where it ran. - expect(content).toContain(`If you ran it on ${label}, that's why: the listener can't answer @mentions from ${label} yet`); + expect(content).toContain(`If you ran it on ${label}, that's likely why: the listener answers @mentions only from a Mac for now.`); + // The server cannot tell Claude Code from Codex, so no adapter-specific cause. + expect(content).not.toContain('sandbox'); expect(content).toContain('Run `commonly agent run ai1-agent` on a Mac'); expect(content).toContain('or choose “Run it here” on the Bring your own page for a hosted agent'); expect(content).toContain("I'll post here the moment I connect"); diff --git a/backend/services/stalledConnectCopy.ts b/backend/services/stalledConnectCopy.ts index ffd7e50a7f..73c0dda4f2 100644 --- a/backend/services/stalledConnectCopy.ts +++ b/backend/services/stalledConnectCopy.ts @@ -49,12 +49,12 @@ export type StalledConnectCopyEvidence = { }; /** - * The listener answers @mentions only on a Mac for now: Commonly runs Claude - * Code and Codex in a sandbox, and neither sandbox runs them on Linux or - * Windows (V2AgentBYO's listenerRunsOn). The page says so with - * agentByo.listen.unavailableClaude / unavailableCodex; this is the same - * sentence after the fact. The server never learns which adapter the copied - * command named, so the copy names the listener, not the CLI. + * The listener answers @mentions only on a Mac for now (V2AgentBYO's + * listenerRunsOn), for a different reason per adapter: Commonly's sandbox + * cannot run Claude Code on Linux or Windows, and Codex there is not yet + * tested (agentByo.listen.unavailableClaude / unavailableCodex). The server + * never learns which adapter the copied command named, so the copy names the + * listener, not the CLI, and states only what is true for both: Mac only. * * It hedges, unlike the rest of this file. The page shows the listener command * only on its Mac tab, so a copy from a Linux or Windows browser means the @@ -106,8 +106,8 @@ export const composeStalledConnectNudge = ({ if (unavailableOs) { return `@${installerName} — you copied the listener command ` + `${copiedAtDescription(offsetSeconds)}; no connection arrived. ` - + `If you ran it on ${unavailableOs}, that's why: the listener can't answer @mentions from ` - + `${unavailableOs} yet, because Commonly runs it in a sandbox that can't run there. ` + + `If you ran it on ${unavailableOs}, that's likely why: the listener answers @mentions ` + + `only from a Mac for now. ` + `Run \`${fixCommand}\` on a Mac, or choose “Run it here” on the Bring your own page for a hosted agent. ` + `I'll post here the moment I connect.`; }