The backend's modelRecommendations entries are now { assistantModel,
taskModels? } (rowboatx-backend#18), mirroring models.json v2 vocabulary.
When a provider connect/sign-in seeds the assistant (and ONLY then), its
per-task recommendations become visible taskModels overrides — each
validated against the live list and skipped when equal to the chosen
assistant (only differences are written, same principle as the migration).
This restores pre-v2 economics for signed-in task models without hidden
defaults: fresh Rowboat sign-ins, new devices, and sign-out→sign-in round
trips all land with the lite-tier task models the app used to hardcode —
server-controlled, updateable without a release. Saved choices are still
never touched: no seeding happens when an assistant already exists.
- selection logic moves to @x/shared/initial-selection (core re-exports;
the renderer connect flow uses the same implementation)
- RowboatApiConfig accepts both the legacy flat shape and the nested one
(normalizeModelRecommendation), so backend deploy order and rollback
are non-events
- llm_initial_model_selected gains task_overrides_seeded
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Privacy rules encoded in one place (analytics/model-providers.ts): only
provider FLAVORS leave the app — never instance ids (future-proofing for
user-named instances), never apiKey/headers, never baseURL (local endpoints
can carry internal hostnames). Model ids allowed.
- llm_usage.provider now reports the flavor: captureLlmUsage maps the
instance id centrally (lazy, cached, falls back to the raw value — which
today equals the flavor, so history stays continuous)
- llm_provider_connected / _disconnected {flavor}: one event family across
all surfaces — BYOK at the repo choke point (new entries only; key
rotation is not a connect), rowboat at OAuth sign-in/out, codex at
ChatGPT sign-in/out
- person properties synced on every launch and after provider/assistant
changes: llm_provider_flavors (sorted, auth-derived included),
llm_provider_count, assistant_model + assistant_model_flavor
- llm_initial_model_selected {flavor, model, recommended, source}:
recommendation hit-rate for the initial-selection flow (connect /
onboarding / sign_in)
- models_config_migrated {had_assistant, materialized_overrides,
provider_count}: one-shot rollout health for the v2 migration
- ANALYTICS.md updated (llm_usage provider row, new lifecycle section,
person-properties table)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task slots (config + runtime):
- taskModels gains backgroundTask and subagent; getBackgroundTaskAgentModel
resolves its own override instead of mirroring live-notes (migration
materializes the old mirror rule so effective models are unchanged)
- spawn-agent precedence: explicit per-spawn args > configured subagent
override > parent turn's model
- repo.setProvider rejects auth-derived flavors (rowboat/codex) and merges
over existing entries so key rotation keeps contextLength/reasoningEffort
Models settings, rebuilt:
- ModelSelectionSection: ONE Assistant model picker (with an Unavailable
badge when the saved model drops off its provider's live list) + all
seven tasks defaulting to "Same as Assistant" with resolve subtext and a
clear-override link. Replaces both the signed-in and BYOK screens; the
"Auto (recommended)" sentinel and both hardcoded preferredDefaults maps
are gone — connect-time resolution uses backend recommendations
- ProvidersSection replaces the old ModelSettings card grid: status cards
(Connected · N models / error + Retry), an add-provider dialog
(choose → creds → loading → first-vs-more result states → error with
manual-model fallback), and a manage dialog (masked key + Replace,
endpoint, Refresh models, Used-by list, disconnect with consequences
spelled out). Rowboat and ChatGPT are cards in the same list
- connecting a provider seeds the assistant ONLY when none is configured —
never replaces a saved choice
- models:getConfig IPC serves selections + credential-free provider
metadata; the probe machinery (use-provider-models, liveCredentials)
is deleted — the add flow validates credentials click-driven
Onboarding:
- screen 2 is the same ProvidersSection (onboarding variant) for BOTH
paths: "Add more providers" after sign-in, "Connect a model provider"
otherwise; Continue gates on an assistant model existing. The tile grid,
per-provider key forms, and ~350 lines of duplicate state are deleted;
one step sequence replaces the two path-dependent indicators
Picker:
- split-view browse (Popover + cmdk Command): providers left with counts
and error dots, selected provider's models right, ←/→ switches provider,
↑/↓ navigates, typing collapses to a flat cross-provider list that
matches provider NAMES as well as model ids (searching "rowboat" now
works). Scoped/static pickers stay flat; public API unchanged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v1 config fossilized three schema generations: a top-level
provider/model pair (single-provider era), a providers map duplicating
credentials AND caching model lists (pre-dynamic-listing era), plus
defaultSelection and flat category overrides (hybrid era) — while several
effective models existed only as hidden curated branches in code.
v2 is the intended shape:
{ version: 2,
providers: { <instance-id>: { flavor, apiKey?, baseURL?, … } },
assistantModel: { provider, model },
taskModels: { knowledgeGraph?, meetingNotes?, liveNoteAgent?,
autoPermissionDecision?, chatTitle? },
deferBackgroundTasks? }
- one-time boot migration (core/models/migrate.ts, invoked from
ensureConfig): evaluates the v1 resolution rules — including the curated
signed-in defaults — one last time and writes the answers explicitly, so
the simplified resolvers produce identical effective models; task
overrides are written only where the old effective model differs from
inherit-from-assistant; curated ids survive solely as frozen literals in
the migration
- defaults.ts: getDefaultModelAndProvider reads assistantModel, period;
getCategoryModel/getChatTitleModel are "override else assistant"; all
SIGNED_IN_* constants and the repo's gpt-5.4 bootstrap deleted
- rowboat sign-in = connecting a provider: with no saved assistant, the
initial model is picked via selectInitialModel (backend recommendation
if the gateway lists it, else first listed) and saved; sign-out clears
rowboat-referencing selections (same dangling-ref cleanup as removing
any provider). A saved assistant is never replaced — signing in no
longer hijacks a BYOK selection
- IPC: models:saveConfig replaced by models:setProvider/removeProvider;
models:updateConfig speaks v2 keys; models:test takes {provider, model}
- renderer call sites (settings dialog, onboarding, composer) translated;
the settings dialog's delete-provider path collapses from ~100 lines of
top-level-pair juggling to removeProvider + best-effort promotion
- migration dry-run verified against a real-world signed-in config
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): show chat timestamps in the user's local timezone
The per-message "Current date and time" context was built with toISOString(), giving the model a UTC time frame with no zone. It then quoted email Date: headers (sent in +0000) in UTC — an email shown as 1:06 PM IST in the inbox was described as 7:36 AM in chat.
Use local wall-clock via toLocaleString with an explicit timeZoneName and the IANA zone (matching what the legacy engine already did) so the model converts offset-bearing timestamps to local time.
* fix(x): convert email timestamps to local time for the assistant
The "now" fix gave the model a local time frame, but email dates still reached it as raw RFC 2822 Date: headers (marketing mail is sent in +0000), so it kept quoting them in UTC — a 1:06 PM IST email described as 7:36 AM in chat.
Add formatEmailDateLocal() and apply it at the three model-facing serialization points: the read-view email tool, the thread-summary line, and the gmail-sync knowledge markdown (**Date:**). The structured date field on snapshots/messages is left raw, since the renderer parses it with new Date() and formats it client-side.
* fix(x): generalize local-time formatting for model-facing timestamps
Move formatEmailDateLocal out of sync_gmail into a shared
formatTimestampForModel() util — email is just the first source of
external timestamps; Slack/calendar serialization can now reuse the
same one-line call instead of growing per-source formatters. Also add
a system-prompt instruction to always express times in the user's
local timezone, as a fallback for raw dates inside email bodies, web
content, and third-party tool output that pre-conversion can't reach.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- RowboatApiConfig gains optional modelRecommendations (one model per
provider FLAVOR, native id format) matching the merged backend payload;
optional so older API deployments and failed fetches never break parsing
- new rowboat:getConfig IPC serves the unauthenticated /v1/config bootstrap
independent of sign-in (signed-out BYOK users need recommendations when
connecting a provider); account:getRowboat is back to pure account state
(signedIn/accessToken) — config is no longer a property of the account
- renderer useRowboatConfig() hook: one shared store for all config
consumers (appUrl dialogs/sidebar/settings), plus imperative
fetchRowboatConfig() for event-time consumers (voice, meeting) that must
await the value when starting a session
- selectInitialModel(flavor, models, recommendations): pure spec-ordered
initial pick (recommendation if listed, else first, else null) — runs
only when a provider is first connected, never over a saved choice
Inert plumbing: nothing calls selectInitialModel yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One function in core (getModelCatalog) now answers "which providers are
connected and what models does each offer", treating every provider the
same way — Rowboat gateway, ChatGPT subscription (codex), BYOK keys, and
local endpoints. Each entry carries { id, flavor, status, savedModel,
models }: id is the provider *instance* (what ModelRef.provider joins on),
flavor is the provider *type* (display naming, listing mechanics) — one
instance per flavor today, so id === flavor key, but a future multi-key
setup stays additive.
- models:list reshaped to serve the catalog plus the effective default;
req gains optional refreshProvider (Retry / Refresh models)
- main-side list cache keyed on a credential fingerprint (auto-invalidates
on key change; failures retry after 30s) — the gateway is no longer hit
on every snapshot rebuild
- useModels collapses from 4 IPC calls to 1: no more renderer-side flavor
sets, sign-in awareness, or raw models.json reads
- ModelSelector drops the live-group fetch machinery; groups render
uniformly with an inline error row + Retry. The only renderer-side fetch
left is probing typed-but-unsaved credentials (settings forms)
- channels bridge lists from the same catalog, so WhatsApp/Telegram
pickers now match the desktop picker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3.5-flash reasons by default and starved the 50-token output cap
(finishReason: length, 44/50 tokens on thinking). Map reasoning effort
to low via the existing per-provider translation, raise the cap, and
keep only the first output line.
The gateway allowlist has no google/gemini-3.5-flash-lite (only
3.1-flash-lite and full 3.5-flash), so signed-in title calls 403'd
'Model not allowed' and silently kept the placeholder.
Flash-lite under-complied with a bare word-count rule (returned
one-word titles). Few-shot examples rein it in; a code guard rejects
any remaining single-word output so the placeholder stays instead.
A tiny background LLM call names new chats in 3-5 words, replacing the
truncated-first-message placeholder once it lands. Manual renames always
win; failures silently keep the placeholder.
Model resolution (getChatTitleModel): chatTitleModel override in
models.json, else the curated gateway flash-lite when the assistant
default routes through the gateway, else the assistant's own provider -
so signed-in users who switched their default to BYOK get titles on
that provider instead of a dead gateway call.
parseChartSource extracted as a pure function with tests for the three
render states (streaming / invalid / parsed), the label/value alias
mapping, and multi-series y arrays. doc-collab's chart block reference
now mentions the array form of y.
Live testing showed models emitting pie configs with label/value
instead of x/y, and long-format line data with a series column —
schema-invalid fences sat on 'Preparing chart…' forever. The renderer
now maps the label/value aliases before validating, distinguishes
complete-but-invalid JSON (explicit 'config invalid' state) from
still-streaming JSON (placeholder), and the skill spells out the exact
field list, pie's x/y meaning, and wide-vs-long data format with a
wrong/right example.
The chat's markdown fence override now renders ```chart blocks
(the same ChartBlockSchema notes use) as interactive recharts inline —
the mermaid pattern. A new ChartRenderer handles line/bar/pie with
multi-series support, light/dark theming, and a quiet placeholder
while the fence body is still streaming.
ChartBlockSchema.y widens to string | string[] for multi-series
charts (notes chart block updated to map series too), with a
chartSeries() helper in shared.
A new 'charts' skill teaches the model the fence format, form
selection (line/bar/pie, no mixed scales), and data honesty rules;
loaded on demand like any skill.
Address review: remove ITurnLimitsResolver, its bridge and container
registration. createTurn now defaults an omitted maxModelCalls straight
from the turn-limits config (lazy import, built-in fallback), with no
chat/headless distinction in the runtime — the chat UI passes its
optional chat override explicitly via the existing sessions:sendMessage
maxModelCalls field. turn_limits.ts now uses fs/promises throughout.
Per review: no special handling in the agent loop when the model-call
limit is hit — the turn fails as before. Instead the default limit
rises from 20 to 50 (global, and chat inherits it) so the limit rarely
triggers in practice; users can tune it in Settings > Advanced. The
friendly limit-failure message in the chat error bubble stays, since it
points users at the setting.
Interactive turns no longer die with a raw 'Model call limit of N
reached' error. The final budgeted model call is composed as a wrap-up:
the durable request records wrapUp: true, and the composer strips tools
and appends a notice telling the model to answer with what it has,
state what's unfinished, and mention the limit is configurable in
Settings -> Advanced. The turn then completes normally with that answer.
Headless turns (and a wrap-up call that still emits tool calls) keep
the hard model-call-limit failure so automation retains the
machine-readable outcome; sub-agents already surface partial results.
If a hard limit failure still reaches the chat UI, the error bubble now
explains the limit and points at the setting instead of the raw error.
Validation range is now 1-500. The Advanced tab's native number inputs
are replaced with a segmented minus/value/plus stepper (custom buttons,
digits-only typing, range clamping, immediate save on step). The chat
override gets a smaller placeholder, steps from the effective global
limit when empty, and has a clear button to fall back to the global
limit.
Replace the hardcoded 20-call turn budget with user settings: a global
model-call limit that every turn inherits (headless/knowledge work,
scheduled agents, sub-agents) plus an optional chat-only override for
interactive turns. Settings live in config/turn_limits.json (1-100,
default 20) and are edited from a new Advanced tab in Settings.
The limit is resolved at turn creation via an ITurnLimitsResolver
injected into TurnRuntime, keyed on humanAvailable; explicit per-call
overrides still win, and the resolved value is persisted in
turn_created.config.maxModelCalls as before, so historical and
in-flight turns are unaffected by settings changes. Sub-agents now
default to and are capped by the global limit instead of the constant.
* feat(x): first-time-action credit rewards
Reward signed-in free-tier users the first time they connect Gmail, send
an email, take meeting notes, set up a background agent, or build an app.
- core credits service: POST /v1/billing/credit-activations, once per
(user, code); local claimed cache in config/credit_activations.json
keyed by user; 409 reconciles claimed state; other failures retry on
the next occurrence
- triggers at the action success sites in main/core (google oauth
success, gmail:sendReply, meeting:summarize, bg-task create sites,
apps:create); grants broadcast to windows via credits:didActivate
- eligibility: signed-in free tier only (paid starter/pro excluded);
whole feature behind the PostHog credit-rewards flag (default ON,
flag acts as kill switch; ROWBOAT_CREDITS env override for dev)
- UI: sidebar 'Earn $X in credits' pill with checklist popover
(dismissible, rows navigate to each action), settings Account section
with earned state + bonus balance, confetti celebration on grant
- shared: credits catalog/types, credits:getState IPC, BillingInfo
gains store bucket; decodeJwtPayload moved to auth/jwt.ts for reuse
Backend catalog codes deployed separately (rowboatx-backend).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): fetch reward catalog from the API, hardcode only activity codes
Review feedback: names, descriptions, and credit amounts are backend-owned
and subject to change; the app now reads them from GET /v1/config
'creditActivations' instead of a hardcoded catalog. Only the activity codes
remain in the app — they anchor the trigger call sites and per-code UI
(icons, navigation).
- shared: drop CREDIT_ACTIVITIES; add CreditActivationCatalogEntrySchema;
RowboatApiConfig gains optional creditActivations
- core: getCreditsState joins the fetched catalog (unknown codes dropped,
API order preserved) with local claimed flags; celebration title comes
from the catalog with the code as fallback
- renderer: description now optional; settings section hides while the
API serves no catalog
Requires the API to serve creditActivations: [{code, displayName,
description?, credits}] on /v1/config; until then the rewards UI stays
hidden and activations still work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): invite-a-friend referral rewards
Surfaces the backend's referral codes (GET /v1/referral, POST
/v1/referral/claims) in the app. The inviter's permanent code appears as
an "Invite friends" item in the sidebar rewards popover and the settings
rewards list, with copy button and claims progress ($5 per friend who
joins, up to 3). The invited side redeems via a self-gating "Have an
invite code?" entry in onboarding's completion step and in settings;
a successful claim fires the existing celebration and refreshes the
balance. Backend error copy (unknown code, own code, already claimed,
cap reached, account too old) is shown inline verbatim.
The one-lifetime-claim state is cached per user in the local claimed
store (pseudo-code referral_claimed), reconciled when the backend
answers "already claimed" from another install. Referral status is
cached for 60s and busted after a claim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds the model-call half of "Use your ChatGPT subscription": a new
credential-less "codex" provider flavor (like "rowboat") that runs calls
against the Codex backend using the OAuth session from chatgpt-auth.
- core/models/codex.ts: AI SDK OpenAI provider in Responses mode. A
wrapLanguageModel middleware injects providerOptions.openai.store=false
on every call so the SDK emits full self-contained items (text +
reasoning with encrypted_content) instead of item_reference entries the
stateless backend 404s. A wire-level fetch wrapper adds auth
(bearer + chatgpt-account-id) and Cloudflare headers (originator), and
backstops the contract: store:false, stream forced with SSE aggregation
for non-streaming callers, max_output_tokens dropped, encrypted-
reasoning include, friendly usage-limit errors
- live model discovery via /codex/models, gated by client_version
(mirrored from the local codex CLI's models_cache.json so the catalog
matches what `codex` shows); visibility "hide" utility models filtered;
display names carried through; hardcoded fallback list for offline
- createProvider/resolveProviderConfig/mapReasoningEffort codex cases
- models:list merges the "OpenAI Codex" catalog while signed in with
ChatGPT; chatgpt:statusChanged push event on sign-in/out
- composer picker shows the group gated on the session and refreshes on
status changes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lets users connect their ChatGPT Plus/Pro subscription instead of pasting
an API key. Sign-in half only: the codex model client consumes the session
via getChatGPTAccessToken().
- PKCE OAuth against auth.openai.com using the official Codex CLI public
client id (constants verified against the openai/codex sources); system
browser + loopback callback server on the pre-registered fixed port
127.0.0.1:1455, state validated, stale callbacks rejected
- token store (core): single config/chatgpt-auth.json holding non-secret
display fields (email, accountId, expiresAt) plus the token material
encrypted via the safeStorage cipher bridge (plaintext-with-marker
fallback when no keychain, matching the GitHub token path); no token
value ever written in the clear or logged
- getChatGPTAccessToken(): single-flight refresh within 5 min of expiry;
refresh rejection (400/401) clears to a clean signed-out state and
throws typed ChatGPTAuthRequiredError; 5xx/network kept as transient
- sign-out with best-effort token revocation
- IPC chatgpt:getStatus/signIn/cancelSignIn/signOut — raw tokens never
cross to the renderer; real cancellation settles the in-flight attempt,
frees the port, and a retry starts a fresh attempt (new PKCE/state)
- settings UI: ChatGPT Subscription section on the OpenAI card
(sign in / waiting + cancel / connected as {email} + sign out) via a
useChatGPT hook
Vitest covers the token store, single-flight refresh, and
transient-vs-terminal refresh handling.
Apps section improvements:
- Landing with no apps of your own now falls through to the Catalog tab
with a banner pointing at installing from the catalog or building your
own (links into the copilot app-builder flow).
- Catalog renders as a card grid matching the My Apps visual language
(shared card-theme helpers) instead of one-line rows, with star
button, INSTALLED badge, and Install/Open actions on each card.
- Apps can be pinned to the nav sidebar: right-click an app card to
add/remove; pinned rows sit under the Apps nav item, click opens the
app, right-click removes. Persisted in localStorage (x:pinned-apps).
- My Apps shows a hint that right-click pins an app to the sidebar.
- Uninstall/delete unpins the app, and the Apps view prunes pins for
apps that no longer exist (only off an authoritative server list).
GitHub stars rate-limit backoff (packages/core/src/apps/stars.ts):
once GitHub reports the budget spent (403/429 + x-ratelimit-remaining:
0), gate all star fetches until the advertised reset, serving stale
cached counts instead of burning requests on guaranteed 403s.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(x): client auto-update with non-interrupting UX
Auto-update via update.electronjs.org (Squirrel), replacing the native
update dialog with a state machine pushed to the renderer:
- Non-modal "restart to update" toast, deferred while a call/turn is
active and retracted if one starts; 24h "Later" snooze persisted in
main so it survives window reloads
- Offline detection: network errors get a soft `offline` state instead
of a red failure, and don't emit update_failed analytics
- Update-waiting badges: macOS dock badge, Windows taskbar overlay icon
- macOS move-to-Applications prompt parented to the main window, with a
failure fallback dialog and focus re-check after a manual drag
- Settings > Help: version, manual check with accurate transient
"You're up to date" feedback, per-state messaging
- "Updated to vX" card on first launch after an upgrade (downgrades
restamp silently); "What's new" links to release notes
- Toaster: follow app dark mode (fixes unreadable description text) and
restyle to theme tokens
- Window state persistence so restart-to-update feels lossless
- Tests for version stamping and upgrade comparison
* feat(x): replace update toast with Zed-style titlebar chip
The restart-to-update prompt moves from a sonner toast to a persistent,
non-interrupting titlebar indicator: a spinner while an update downloads,
then a 'Restart to update' chip once staged. Clicking restarts into the
new version; the x snoozes the chip for 24h via the existing persisted
snooze. Busy-deferral is dropped - the chip never interrupts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): bottom-left update card with inline release notes
Reframe the restart prompt per PR feedback: by the time the user sees it,
Squirrel has already installed the update — the prompt only asks for a
restart (Chrome-style), and being loud about what shipped matters more
than being unobtrusive during early adoption.
- Replace the titlebar chip with a bottom-left "Update available" card
showing the new version, an inline "What's new" section rendered from
the GitHub release notes, and Later / Release notes / Restart now
- Release notes come from Squirrel.Mac's update feed (update.electronjs.org
passes the release body through); Squirrel.Windows only reports the
release name, so missing notes are backfilled from the GitHub API
- Drop the 24h snooze machinery — Later/× just dismiss for the session
- Drop offline detection (soft `offline` state) — separate PR later
- Drop the macOS move-to-Applications prompt/move button — separate PR
later; Settings still explains why updates are unavailable outside
/Applications
- Drop window state persistence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): aggregate release notes across skipped versions, persistent up-to-date status
- Updater aggregates release bodies for every version between the running
app and the update target, with a commit-log fallback when no release in
range has notes
- CI fills empty release bodies with GitHub's auto-generated notes
- Settings shows a persistent 'You're up to date' line with last-checked
time instead of a transient confirmation
* fix(x): restore updater:quitAndInstall lines dropped in merge
The conflict resolution in 67d6a542 truncated the updater:quitAndInstall
entry in both the shared IPC schema and the main-process handler, leaving
an unclosed brace that broke the shared package build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): simplify release notes handling per review feedback
Release notes are a process concern, not the updater's: drop the
CI job that auto-filled empty release bodies and the in-app GitHub
API backfill (backfillReleaseNotes + commitLogFallback). The update
card now adapts instead — it renders the notes Squirrel supplies,
or a static "Bug fixes and improvements." line when empty.
Also document that updateElectronApp() configures the same
autoUpdater singleton our listeners observe (no race), and that
gen-install-loading.sh is a manual one-off tool whose committed
GIF feeds Squirrel's loadingGif.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): drop update-electron-app, drive autoUpdater directly
With notifyUser: false the package reduced to setFeedURL + an
immediate checkForUpdates() + a 10-minute unconditional timer, plus
guards we already have (isPackaged, platform, app-ready). Inline
those three lines instead and remove the dependency.
The interval now runs through the existing guarded checkForUpdates()
(no-op unless idle/error), so ticks no longer emit Squirrel.Mac
"check already in progress" errors or make Squirrel.Windows
re-download an already-staged update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Registered as a posthog-js super property in the renderer and merged
into every posthog-node capture/identify in main, plus set as a person
property. Segments desktop traffic from the legacy web dashboard's
anonymous autocapture if it ever shares the PostHog project. Desktop
app only — the web app is legacy and intentionally untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds feature-level instrumentation so dashboards can answer "how
important is X and what do people do inside it":
- view_opened fired centrally from the currentViewState effect — one
event per section visit (email/meetings/bg-tasks/apps/...), plus
one-shot has_used_* person properties for cohorts
- Email: thread opened, compose opened, sent (mode/attachments/
ai_assisted), AI draft generated, archived, trashed, marked unread,
importance/category corrections, bulk category archive, search,
instructions saved, manual sync, send failures
- Meetings: recording started/stopped (duration), popup action
(captured in main — popup window has no PostHog), note opened,
summarize failures
- Calls: call_ended with duration (call_started already existed)
- Background agents: created (manual/coding/copilot), updated,
toggled, run clicked, stopped, deleted; run completed/failed with
trigger captured in the core runner for a true failure rate
- Live notes: saved, toggled, run, stopped, deleted, edit-with-copilot
- Code mode: session created (direct vs rowboat, agent) and
direct-drive messages (direct mode emits no llm_usage otherwise)
- Billing: paywall shown / upgrade clicked by error kind
- Search: opened + result selected; Settings: opened + tab changed
- Notes: created; edited (deduped to one event per note per session)
- Apps: app_rolled_back (rest were already captured in main)
All changes are additive — existing events, identity flow, and person
properties are untouched. ANALYTICS.md updated with the full catalog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 2px progress line drains toward auto-dismiss (now 30s); hovering the
popup freezes both the line and the dismissal. The renderer owns the
countdown; main keeps only a 3-min crash-safety fallback
- soft synthesized arrival chime (WebAudio — no asset, no spawn)
- close button in the top-left corner, hover-revealed
- call-end grace 3s → 1s with a 1s detector poll: notes flow starts
within ~2s of hang-up
feat(x): unified email classifier with labels, triage sections, and user instructions
Replace the two disjoint email classification systems (inline importance
classifier + batch labeling agent) with a single LLM pass that emits
importance, category, knowledge verdict, summary, and a pre-drafted reply.
Classification:
- classify_thread.ts now returns {importance, category, knowledge}; the
category enum and prompt are built at call time from a label registry
(7 built-ins named after Superhuman's vocabulary — Direct, Calendar,
News, Marketing, Pitch, Notification, Receipt — plus up to 12 custom)
- Users create labels in plain text via the new "Email agent instructions"
dialog; an extraction pass syncs them into config/email_labels.json.
Instructions are also injected into every classify/draft call and the
composer's Write-with-AI bar (seeded: Investor / Candidate / Customer)
- Per-thread category corrections are sticky and feed few-shot learning
(email_category_feedback.ts), same pattern as importance corrections
Knowledge graph:
- Delete the labeling agent pipeline (label_emails, labeling_agent,
labeling_state); admission is now the stamped `knowledge: extract|skip`
frontmatter, with legacy noise-tag fallback and a budgeted sweep that
backfills unclassified markdown (build_graph holds unstamped files)
- Guardrails: user-participated threads always extract; user importance
flips never touch the knowledge verdict; classify failures stamp nothing
Inbox UI:
- Sections become Needs you / Waiting on them / Everything else; waiting
state is derived per render (user sent last, others participated), never
stored, so it can't go stale
- Category chips on rows (Direct stays chipless), filter pills with counts,
one-click bulk archive per category, "Reply ready" indicator
- Fix: Everything else section could never load/render under sustained
sync writes (fetch was chained on hasReachedEnd, which live reloads reset)
- mic-monitor now resolves each mic owner's bundle ID (CoreAudio) and
executable path (libproc) natively — the main process never shells out
during capture (child-process spawns fail with EBADF while Electron
holds a capture session; this bit the ps-based first cut)
- detector matches owners by bundle-ID prefix (com.google.chrome,
us.zoom, com.tinyspeck.slackmacgap, …) with executable-name fallback,
and recognizes Rowboat's own capture (com.rowboat / dev electron)
- call-end detection: once a meeting app has been seen on the mic during
a recording, its absence for 3s means the call ended → auto-stop →
summarize — same flow as a manual stop. Arms only when a meeting app
was actually observed, so in-person recordings never auto-stop
- post-summary redirect: Rowboat foregrounds itself on the finished note
(app:focusMainWindow now steals focus); the new meeting_notes_ready
notification stays as a background-only fallback with a toggle in
Settings > Notifications
OpenRouter's public /api/v1/models returned the full catalog even with an
invalid key, so Settings showed Connected while the model call failed with
Missing Authentication header. With a key present we now fetch the
account-scoped /models/user with the Bearer token, so a bad key surfaces
as an error instead of a false Connected; no key keeps the public preview.
Other providers untouched. Note: unlike the rest of this PR, this touches
core (models.ts).
- mic-monitor Swift helper: polls CoreAudio for mic-in-use, with owning
PIDs on macOS 14.4+ (process-object API) so calls attribute to the app
that actually holds the mic (Chrome vs Zoom vs Slack); no TCC permission
needed. Compiled best-effort by bundle.mjs; exits when stdin closes
- core meetings/detector: 5s mic debounce, PID→process attribution with
running-app fallback, Granola labels (Huddle/Call/Meeting detected),
calendar merge (event start −15min through end), once per mic session
(30s idle reset), suppressed while Rowboat itself captures (recording
or assistant voice call), gated by a meeting_detection notification
category (Settings > Notifications)
- popup: lean Granola-style bar (title + platform + Take notes pill,
floating ×) in a transparent non-activating panel at screen-saver level
with fullscreen-auxiliary behavior — floats over fullscreen meetings on
the cursor's display; activation policy restored immediately so the
Dock icon survives and Take Notes can foreground Rowboat
- Take Notes routes into the existing take-meeting-notes flow with a
synthetic or matched calendar event (source: detected); never records
without a click
- tray icon bumped to 18pt
- Register as an OS login item on first packaged run (default on); the OS
registry stays the source of truth afterward, with a toggle in
Settings > Appearance > System
- Menu bar tray with template icon (derived from the app glyph, embedded
as base64): Open Rowboat / Start-Stop meeting notes / Quit
- App keeps running with no windows while the tray exists; launches at
login start hidden (menu bar only), Dock/tray reveals the window
- Tray menu reflects live recording state via meeting:setRecordingState;
tray-initiated toggles survive window-loading races through a pending
command drained on renderer mount (same pattern as deep links)
- GRANOLA_PARITY.md: research doc — how Granola works, current-state
audit, gap analysis, phased parity plan
The apps-dir watcher recursed into anything an installed app ships;
ignore .git and node_modules segments like the workspace watcher does.
(The skills watcher already bounds itself with depth: 1.)
fs.watch failures (EMFILE/ENOSPC) escape chokidar's error handler as
uncaught exceptions from Node watcher internals and killed the main
process with the native error dialog. Catch them at the process level
and log instead — watching is degradable; other uncaught exceptions
keep the default dialog behavior.
chokidar v4 silently skips a watched directory that doesn't exist at
watch time, so e.g. calendar_sync/ created by the first sync after app
start would never emit events until restart. Create the dir roots up
front; for the file root only its parent dir is needed.
The workspace watcher watched all of ~/.rowboat recursively. Chokidar v4
(no fsevents) holds one OS watch handle per file, and internal state dirs
(runs-archive, storage/turns, engines, ...) grow unboundedly with usage —
~20k open fds on a 1.1G workdir, crashing the app with EMFILE once the
fd limit is hit.
Only watch the roots whose change events actually have consumers:
knowledge, bases, inbox_lists, gmail_sync, calendar_sync, bg-tasks and
config/agent-schedule.json — with .git ignored (knowledge/ is a git repo).
Bump apps/x from AI SDK v5 to v7 (single hop through the 6-0 and 7-0
migration guides). All deps upgraded via pnpm --filter:
ai@7, @ai-sdk/{anthropic@4,google@4,openai@4,openai-compatible@3,provider@4},
@openrouter/ai-sdk-provider@3, ollama-ai-provider-v2@4.
Changes (per the official migration guides):
- Provider spec bumped V2->V4: ProviderV2 -> ProviderV4 (models.ts, gateway.ts)
- streamText: result.fullStream -> result.stream (+ StreamTextInvoker seam,
test fakes); stepCountIs -> isStepCount
- system -> instructions on SDK generateText/streamText/generateObject calls
(left our own generateObjectSafe.system param untouched)
- allowSystemInMessages: true on calls passing stored messages (v7 rejects
system-role messages in the array by default; convertFromMessages emits them)
- image content part {type:'image'} -> {type:'file'} (message-encoding.ts)
- tool() no longer accepts a `name` key (removed; tools keyed by ToolSet key)
- usage token relocation: cachedInputTokens -> inputTokenDetails.cacheReadTokens,
reasoningTokens -> outputTokenDetails.reasoningTokens (mapUsage rewrite);
renderer decoupled from LanguageModelUsage to a local flat UsageSummary
- providerMetadata relay cast to shared ProviderOptions (spec loosened)
No persisted-data changes: @x/shared is AI-SDK-free, so old chats
list/view/continue unchanged. Full typecheck clean; 637 tests pass
(shared 107, core 447, renderer 83); main.cjs bundles ESM-only ai into CJS.
Pending live check (needs creds): OpenAI strictJsonSchema now defaults true
in v7 and may reject zod schemas for openai-family flavors at runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A sync-tool batch runs under Promise.all, which rejects the moment one
tool's result append fails (a transient fs fault, or errorMessage()
throwing on a nullish thrown value). That rejection propagated out
through withLock and freed the per-turn lock while sibling tools were
still executing. A straggler's later append then ran with no lock held
and validated against a stale in-memory history rather than the file —
so if the caller re-advanced in between (writing the recovery
indeterminate result for the not-yet-settled tool), the straggler
appended a second tool_result for the same call. Duplicate results make
reduceTurn throw on every future read, permanently corrupting the turn
file and, through context references, every session behind it.
- executeAllowedTools now awaits Promise.allSettled and rethrows the
first fault only after every tool has finished appending, so a fault
never orphans a sibling.
- advance() drains the append queue (settleAppends) before withLock
releases the turn, closing the fire-and-forget reportProgress path and
any future append-path rejection.
- errorMessage() uses optional chaining so a `throw null`/`throw
undefined` yields a normal isError tool result instead of a TypeError
that escapes as an infrastructure rejection (the same fault that
triggers the lock-escape mid-batch).
Adds two regression tests (both fail without the fix): a nullish throw
records an isError result and completes the turn; a faulted batch does
not settle until the slow sibling's append is durable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>