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>
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>
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.
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.
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.
The budget notice now tells the model to answer the user's request with
the content it already gathered before noting what's unfinished, and
the fallback limit-failure bubble drops the 'work is saved' phrasing.
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.
* 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>
Three capability upgrades to the embedded browser pane:
- Screen share: getDisplayMedia() in the browser partition now shows a
Chrome-style source picker (screens/windows with thumbnails, optional
system audio for screens) instead of silently capturing the primary
screen. Requests are denied when the pane is hidden. The app's own
session keeps its auto-loopback handler for meeting transcription.
- Notifications: the browser partition grants the notifications
permission so sites (WhatsApp Web, Gmail, ...) can post native OS
notifications while loaded. Background Web Push remains unavailable.
- Extensions: electron-chrome-extensions wired to the browser session.
Unpacked extensions load from ~/.rowboat/extensions/<name>/; tab
lifecycle is mirrored into chrome.tabs, and a <browser-action-list>
toolbar hosts action icons/popups next to the address bar. NOTE: the
library is GPL-3.0/Patron dual-licensed — licensing decision required
before shipping in a release build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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
- 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 permission checker returned {required: false} for every non-builtin
toolId and for any builtin outside the executeCommand/file-tools switch,
so composio-execute-tool (email sends, GitHub/Jira writes), executeMcpTool,
and mcp:* attachments on user agents executed with no permission check —
even in manual mode — and never reached the auto-permission classifier.
The extension contract was inverted: a new side-effecting tool shipped
ungated unless someone remembered to extend a switch in another module.
Every builtin now declares its permission policy in the catalog itself
(required field, compile-enforced): "none", "prompt", "command-allowlist",
"file-boundary", "composio-execute", or "mcp-execute". The checker reads
the declaration and FAILS CLOSED: undeclared builtins, mcp:* attachments,
and unknown toolId families require permission. Composio and MCP requests
carry family-specific payloads (new ToolPermissionMetadata kinds, rendered
by the permission card; generic fallback for everything else). All
previously-ungated builtins keep today's behavior via explicit "none"
declarations, except addMcpServer which now prompts; a catalog test pins
the audited gated set so policy changes stay intentional.
Auto-permission flows (background tasks, live notes, channels) route the
newly gated calls through the existing classifier per the §9.3 matrix;
defer still denies without a human. No grant persistence yet — every
gated call prompts (or classifies) each time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test files were typechecked by nothing: tsconfig.build.json excludes
them (correctly — they must not land in dist), and vitest transforms
with esbuild, which strips types without checking them. That gap is
where the review's two tsc breaks and the fixture drift accumulated.
Adds 'typecheck' scripts per package (dev tsconfigs, which include
src/**/*.test.ts; renderer reuses its existing tsc -b), a root
orchestrator mirroring the test script, and a CI step in the vitest
job. All three packages are currently clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Widens the models.dev schema to keep the per-model `reasoning` boolean
and carries it through normalizeModels → ProviderSummary → models:list,
so the composer can gate the reasoning-effort control on actual model
capability. Gateway model lists (bare "vendor/model" ids from the
server) are annotated from the models.dev cache in one batched,
cache-only read; unknown models keep the flag absent, which the UI
treats as "hide the control".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a canonical reasoning-effort ladder (low|medium|high, absent =
auto/provider default) that travels: send config → turn_created.config →
every model call's persisted parameters (§8.3) → provider-specific
options at invoke time. Behavior-neutral: nothing sends an effort yet,
and auto produces byte-identical requests to today.
- shared: ReasoningEffort enum (single source, reused by models.json
provider config), TurnCreated.config.reasoningEffort (additive
optional on a non-strict object — old builds strip it, no
schemaVersion bump), sessions:sendMessage IPC schema.
- turns: CreateTurnInput/SendMessageConfig/HeadlessAgentOptions carry
the value; runModelStep stamps it on each call's parameters so every
step durably records what it ran with.
- bridge: maps canonical effort to provider options, transport-only
like prompt caching — OpenAI reasoningEffort, Anthropic thinking
budgets (raising maxOutputTokens to the budget floor, never lowering
an explicit value), Gemini thinkingLevel/thinkingBudget by
generation, OpenRouter/rowboat reasoning.effort. Capability-gated
via a cache-only models.dev lookup (never blocks a turn on the
network); unknown support fails closed on strict flavors, while
OpenRouter-shaped flavors map permissively since OpenRouter drops
the field for non-reasoning models. Explicit persisted
providerOptions win over the mapping. Ollama keeps its existing
provider-level think rewrite; openai-compatible endpoints get
nothing (no safe universal parameter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the turn-spine unification. Chat's SessionChatStore now
consumes turns:events like every other surface, joining live turns by
file offset (drop covered, append contiguous, refetch on gap) instead
of blind-appending session-bus events. Text/reasoning deltas cross IPC
only for turns a window subscribed to (turns:subscribe/unsubscribe, a
per-webContents registry that also survives window teardown), so
headless pipeline chatter never reaches windows that aren't watching.
The channels bridge settles turns off the turn event bus instead of
filtering the session broadcast. With no turn-event consumers left,
SessionsImpl stops forwarding entirely (it drains its execution streams
and keeps outcome/index handling) and sessions:events shrinks to
index-changed entries — one channel for turn events, one for session
metadata.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TurnRuntime now publishes every turn's events (durable ones tagged with
their 1-based file offset, deltas without) to an injected TurnEventHub,
regardless of who started the turn. Main forwards durable events to all
windows on one turns:events channel; the renderer's new useTurn(turnId)
hook joins live turns gap/duplicate-free via the offset protocol. The
sub-agent card drops its 1s polling for push updates, and the nine
per-channel window fan-out loops collapse into one broadcastToWindows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Skills now own their tools. The copilot attaches only a small hardcoded
base set (~16 tools instead of all ~42); loading a skill via loadSkill
attaches its declared tools as native tool definitions from the very
next model step, and they stay attached for the rest of the session.
- tools_extended: new durable turn event (the one sanctioned exception
to per-turn tool immutability — explicit, replayable, never silent).
Reducer tracks extensions per model-call index; effectiveTools()
composes base + extensions for both the live loop and inspect.
- Runtime: sync tool results may carry metadata.toolAdditions; dedupe
and the durable append run atomically on the serialized commit chain
(safe under concurrent sync tools). Crash recovery rebuilds the
extended toolset from the log.
- Skills declare tools (SkillDefinition.tools; SKILL.md tools:/
allowed-tools frontmatter); catalog entries list them; loadSkill
returns them via a reserved $toolAdditions key the registry lifts
into result metadata. builtin-tools skill = escape hatch, derived as
"every non-base builtin" at module init.
- Sessions derive composition.activeSkills from the previous turn's
request + its tools_extended events; the resolver attaches active
skills' tools on top of the base set (stable order, so snapshot
inheritance keeps working).
- Legacy code-mode path keeps working on the base set (which includes
code_agent_run/launch-code-task) and strips $toolAdditions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the agent-as-tool capability deferred in turn-runtime-design §29.2:
a `spawn-agent` builtin that runs a sub-agent in its own standalone
headless turn and returns its final answer to the parent. Multiple
spawn calls in one assistant batch run concurrently (previous commit).
- RequestedAgent is now a union: by-id (unchanged shape) | inline
{name, instructions, model?, tools?}. Inline definitions persist
verbatim in turn_created and resolve to the same immutable snapshot.
- Agent resolution splits by variant: DispatchingAgentResolver narrows
the union once; InlineAgentResolver materializes inline specs
(builtin catalog validation, headless default profile when tools are
omitted); RealAgentResolver keeps the by-id path byte-identical. The
builtin→ToolDescriptor conversion is extracted to a shared helper.
- The spawn handler (RealToolRegistry branch → runSpawnedAgent) runs
the child via HeadlessAgentRunner on the parent's model by default,
clamps the model-call budget at 20, cascades the parent's abort
signal, and records {kind:"subagent", childTurnId} as durable tool
progress — the only parent→child link; no parentTurnId is added to
the schema. Task-level failures return as conversational isError
results, never terminal.
- Depth is capped at 1: both resolvers strip spawn-agent from children
(inline always; by-id via the new subagent composition flag) and the
handler refuses child-shaped parents outright.
- Renderer: spawn-agent calls render as a SubAgentBlock — a collapsed
status card that expands to the child's live transcript
(CompactConversation over sessions:getTurn, polled at 1s while
running; standalone child turns don't reach the session bus).
- The BuiltinTools entry gives copilot (and other catalog-attached
agents) the tool automatically; its execute is the degraded legacy
path only, since the turn runtime intercepts builtin:spawn-agent.
Schema note: RequestedAgent widened under schemaVersion 1 (pre-release)
— requires wiping ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expand the curated toolkit whitelist from 26 to 67 entries, adding
well-known services that support Composio-managed OAuth2 (the only
auth scheme the app's connect flow supports): Zoom, Discord, ClickUp,
monday.com, Confluence, GitLab, Bitbucket, Supabase, Sentry, PagerDuty,
Stripe, Square, QuickBooks, Mailchimp, Google Ads/Analytics/Search
Console, Figma, Canva, YouTube, Instagram, Facebook, Box, SharePoint,
and more. Adds three new categories: design, marketing, finance.
Also fix two entries that were never connectable: 'microsoft_outlook'
and 'onedrive' do not exist in Composio's catalog (the API returns 404,
so initiateConnection always failed). The real slugs are 'outlook' and
'one_drive'.
Excluded per curation policy: Composio's own utility toolkits
(composio_search, codeinterpreter, browser_tool), no-auth toolkits
(Hacker News, OpenWeatherMap), API-key-only toolkits the app cannot
connect (Firecrawl, Tavily, Exa, PostHog, etc.), and niche/low-quality
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a Caffeinate switch (Settings → Mobile channels) that uses
Electron's powerSaveBlocker ('prevent-app-suspension', equivalent to
caffeinate -i) to stop the machine from idle-sleeping so the
WhatsApp/Telegram bridge stays connected. While active, an amber
coffee icon in the titlebar shows the state and can be clicked to
turn it off; a power:caffeinateChanged push keeps the titlebar
indicator and the settings switch in sync.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- catalog is ranked by GitHub star count (per-repo lookup with a 10-min
cache; unauthenticated works, the publish token is used when present)
- star button on each catalog card stars/unstars the app's repo as the
signed-in user (public_repo scope covers it), optimistic with revert
and a sign-in hint when signed out
- the info panel now offers Delete for local apps (apps:delete already
existed and cleans up app-owned agents; only installed apps had a
removal button before)
- start the apps server before createWindow instead of after the full
service-init chain: the Apps view was reachable ~10s before port 3210
was listening, so every app iframe opened to connection-refused
- retry EADDRINUSE binds (quick relaunch races left the server disabled
for the whole session with no retry)
- app frame: auto-retry the iframe instead of a dead manual Retry, and
surface when the apps server itself is down (new apps:serverStatus)
- log render-process-gone/child-process-gone reasons; renderer helper
crashes were previously invisible in packaged builds
- env: GITHUB_OAUTH_CLIENT_ID (device flow enabled on the Rowboat OAuth app;
overridable via ROWBOAT_GITHUB_CLIENT_ID)
- packager (§4.4): allowlist-only .rowboat-app ZIP (yazl), sorted entries,
symlink skip, sha256
- github-auth (§10): device-code start/poll, identity fetch, token stored
0600 with safeStorage encryption injected from main (core stays
electron-free); githubAuth:* IPC + external open of the verification page
- registry client (§9.2): unauthenticated tarball index with 5-min cache and
stale fallback, raw-record resolve, substring search, quota-free
latestManifest via release-asset redirect with name-mismatch guard
- registry repo contents (docs/apps-registry): record JSON schema +
validate-and-merge Action implementing §9.3 checks 1-7 with rejected:<code>
comments and per-name concurrency
- fix: host-api copilot-run adapted to dev's ModelSelection {provider,model}
(this is the same fix dev needs for Ramnique's packaging break)
- pnpm 11: blockExoticSubdeps=false (electron-forge has a git subdep)
The runs->turns migration broke codex live streaming in copilot chat: the
turns bridge's publish shim forwarded only tool-output-stream and silently
dropped code-run-event / code-run-permission-request (the latter would
deadlock a turn under policy 'ask'). An interim fix persisted every stream
event as durable tool_progress, but that wrote each text chunk to the turn
file — unsustainable.
Final architecture — live and durable paths split:
- Live (ephemeral, bypasses the turn runtime): code_agent_run broadcasts
each ACP event on a new CodeRunFeed (core DI singleton), forwarded by
main over a dedicated codeRun:events channel, buffered per toolCallId in
a module-level renderer store and rendered by CodingRunBlock. The buffer
survives session switches; nothing is persisted.
- Durable (one line per run): when the run settles (success, error, or
cancel), code_agent_run publishes a single code-run-events-batch with
the whole ordered timeline, consecutive same-role message chunks
coalesced (display-lossless — the timeline concatenates them anyway).
The turns bridge maps it to tool_progress {kind:'code-run-events'};
turn-view derives the replay timeline from it, so reloads keep history.
- Permissions stay durable per-ask (request + resolved marker): the
renderer overlay resets on session switch, so an ephemeral-only ask
would strand a blocked turn with no card to answer. Pending = requests
minus resolutions (handles concurrent asks), cleared on tool result.
The legacy code-section path (runs bus per-event) is untouched; its
per-event ctx.publish remains and is a no-op under the turns shim.
Also: repaired the two code_agent_run tests broken by the earlier cwd
existence check (they used a nonexistent /repo), and added coverage for
feed broadcast, batch coalescing, partial-batch-on-failure, bridge
durability routing, and pending-permission derivation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: drop the model-layer scheduler (per-call-site
interactive/classifier/background priorities, local-provider hostname
heuristic) in favor of app-level orchestration:
- deferBackgroundTasks flag in models.json, surfaced as a settings
toggle; auto-enabled once (UI logic) when the user connects Ollama
- ChatActivity counter marked by both chat runtimes (sessions layer and
legacy AgentRuntime.trigger)
- startWhenPossible/runWhenPossible wrappers around the headless agent
runner; all background invocations (knowledge pipeline, live notes,
background tasks, scheduled + prebuilt agents) go through them and
wait for chat-idle when the flag is set
createLanguageModel keeps only the Ollama context-window middleware;
the LM Studio capability probe now keys off the provider flavor instead
of hostname sniffing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>