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.
The assistant's read-view email search stored its query in
emailInitialSearchQuery and never cleared it, so every later visit to the
email section remounted EmailView with the old query re-injected into the
search box — even after the user had cleared it. Clear the stored query on
any email navigation that doesn't carry an explicit search (sidebar,
openEmailView) so deep-linked searches apply exactly once.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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
macOS NSPanel windows don't honor transparent: true (nor a #00000000
backgroundColor); the transparent margins around the card rendered as an
opaque grey slab. Drop transparency entirely: size the window to the
card (376×48), paint the card color on the window, use native rounded
corners + shadow. The hover × slides in over the card's left edge
instead of overhanging a corner.
Onboarding still asked for a model name for openrouter/aigateway/ollama/
openai-compatible. It now mirrors the settings dialog: entering a key (or
base URL) is enough and the model is resolved silently from the fetched
list (same precedence), with openai-compatible keeping a visible Model
field as its escape hatch. Save payload shape and multi-provider flow
unchanged.
Bug 1: the composer picker search input lived inside the scrolling dropdown
body as a sticky element, so container padding and Radix's open-focus
scroll left it slightly below the top. Moved it out of the scroll area into
a fixed header with the model list in an inner scroller, so it's flush at
the top and always visible.
Bug 2: Connect gated on resolvedModel, which is empty while the debounced
key-change fetch is still in flight right after pasting a key, so the first
click was a no-op. Connect now gates on credentials only, and the save
handler resolves the model on demand (one fetch, same silent precedence)
when it hasn't loaded yet. Save payload, models:test gate, and per-function
fields unchanged.
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)
- animated sine-wave indicator (braille dot bars, two per cell, 300ms
frames) beside the tray icon while a meeting records
- popup: hover-anywhere reveals the close button (the drag region was
swallowing mouse events), leaner card (48px, 76px window), sits 44px
below the menu bar
- leaner: 400×84 window, 56px card, type stepped down (14/13px)
- close button only appears on hover
- Rowboat sail sits directly on the button background (no chip box),
slightly larger
- dropped the dashed/solid left rule; symmetric margins and padding
- browser preview mode (vite only, no Electron): renders sample data at
the real window size on a backdrop — #meeting-detected, plus
?variant=calendar for the calendar-linked look
Note filenames derive from the meeting title, so every ad-hoc detected
meeting (titled "Meeting") — and any recurring calendar event repeating
its summary — resolved to the same file and each recording clobbered the
previous one's notes. Suffix with the start timestamp when the path
already exists.
- 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>
Each connected provider gets an explicit Disconnect in Settings. When the
disconnected provider was the default, another connected provider is
silently promoted (defaultSelection's provider preferred) through the
existing models:saveConfig path, and the toast names the new default.
Disconnecting the last provider clears the top-level config entirely so
llm:getDefaultModel degrades to the signed-in gateway default or a clean
no-config rejection and the composer shows its connect hint instead of a
phantom default. A defaultSelection pointing at the removed flavor is
dropped in the same write.
The picker dropdown gets a sticky search input filtering model ids across
all groups (headers hide at zero matches; loading/error rows stay visible
as status; a No-models-match row appears when nothing matches anywhere).
openai/anthropic/google groups fall back to a live models:listForProvider
fetch when the models.dev catalog is unavailable (signed-in mode or empty
cache), so they always show the provider's full list.
The composer picker lists every model across all connected providers:
models.dev catalog groups for openai/anthropic/google (plus the rowboat
gateway when signed in) and live per-provider fetches via
models:listForProvider for openrouter/aigateway/ollama/openai-compatible,
each group loading and failing independently with inline Retry. Picking a
model persists as the app default (models:updateConfig defaultSelection)
so background agents and new tabs follow the last pick; per-tab override
behavior is unchanged. Empty state points to Settings; the dropdown
scrolls for large lists and OpenRouter ids containing slashes are handled.
Settings no longer exposes model selection. Each provider connects with
just its API key (or base URL for local providers); the live fetch now
drives a connection status line (Connected - N models) and the model the
config schema still requires is resolved silently at save (saved >
preferred > first fetched). Escape hatches: openai-compatible keeps a
visible Model field, and fetch failures offer manual entry so offline
saves still work. Provider cards show a Connected badge per configured
flavor. Save payload, models:test gate, and the four per-function fields
unchanged. Model selection moves to the chat composer next.
Settings > Models is now key-first, matching the onboarding flow: entering
an API key (or base URL for local providers) fetches the provider's live
model list via the existing models:listForProvider IPC, shown in a
dropdown. Free-text model entry survives only as the fetch-failure
fallback and the openai-compatible custom-model escape hatch. Unkeyed
providers no longer show auto-seeded models. Also fixes the models[] wipe:
editing the primary model previously discarded every saved model after
the first.
Builtins signal failure by returning an envelope ({error}, {success:
false, error|message}, composio's {successful: false}) instead of
throwing, and the tool-registry bridge marked every non-throwing result
isError: false — so the durable log recorded most builtin failures as
successes, misleading the reducer's record, the renderer's error state,
telemetry, and the tools_extended gate. The spec's ToolResultData.isError
channel was effectively populated only by throws.
The bridge now detects the envelope at its single chokepoint and sets
isError accordingly. Model-facing bytes are unchanged (encoding ignores
isError). Deliberately not an error: executeCommand's success:false for
a plain non-zero exit with no error/message — a failing grep is a
result, not a tool failure. Composio success passthrough (error: null)
stays non-error via the truthy-string check. The authoring convention is
documented on the catalog schema.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
advance() resolved the context chain, agent snapshot, live model, and
every snapshotted tool before applying ANY input — including cancel,
which uses none of them. A permanently broken environment (provider
removed from models.json, builtin renamed by an update, context
predecessor unreadable) therefore rejected every advance forever: the
turn could not be cancelled, stopTurn's fallback failed, and the
session rejected sendMessage with TurnNotSettledError permanently — a
bricked session with valid bytes on disk.
Materialization is now a thunk the invocation awaits only for work that
continues the turn: run() short-circuits a cancel input straight to the
cancel path (same commit ritual, same synthetic results, byte-identical
events for healthy turns), while every other input still materializes
first, preserving §15.3's reject-before-touching-the-file ordering.
applyInput narrows to non-cancel inputs.
Tests: cancel succeeds against a broken tool registry (with the
non-cancel input asserted to reject file-unchanged), a broken model
registry, and an unreadable context chain; plus a healthy-cancel
regression pinning the exact event sequence and outcome. All three
broken-environment tests fail against the old code. Design doc §15.3,
§18, §22, and §26.5 updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A message body whose first block has a top margin (a bare <p>, most
Outlook mail) rendered with its final line cut off. The margin collapses
through <body>, so body.scrollHeight under-reports by the margin height
while the content's real bottom sits lower — and that short height is
what we set on the iframe. Measured in Electron: a two-line reply
reported 63px against a 73px content bottom, losing 10px. display:
flow-root on <body> contains the margin (the existing rule only reset the
*last* child's bottom margin). Pre-existing; not caused by quote
collapsing.
Also restore <body> attributes when re-serializing a prepared body.
prepareEmailHtml joined head.innerHTML + body.innerHTML, dropping the
<body> tag — and with it the bgcolor/style that 431 of 584 local
messages rely on for their background (the host parser merges a nested
<body>'s attributes onto its own). Serialize documentElement.innerHTML
instead so the tag and its attributes survive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
executeSyncTool's success-result append sat inside the try whose catch
appends an error tool result. A transient repository failure (disk
pressure, EMFILE) on that append was therefore durably recorded as the
TOOL having failed — after its side effect already happened — inviting
the model to retry the side effect on the next call (double-sent email,
duplicate issue). This violated §21.4: repository failures must reject
the execution, not masquerade as tool outcomes.
The try now covers execution and result parsing only (a tool returning
garbage is still a tool error). The success append happens outside: a
failure there propagates as an infrastructure error, the log keeps the
invocation without a result, and the existing §23 recovery closes it
honestly on re-advance ("outcome is unknown and it was not retried")
without re-executing.
Tests: a repo fake failing exactly the success append — asserts no
false error is persisted, the outcome rejects, recovery yields the
indeterminate result without re-execution, and a committed sibling
result survives untouched with correct model-facing ordering; plus a
parse-failure case pinning garbage returns as tool errors. Both repo
tests fail against the old code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The permission card offered "Allow for Session" and "Always Allow" on the
turns path (full-screen chat and side-pane), but the sessions layer never
persists grants — the scope rode respondToPermission metadata that nothing
consumes, so both buttons behaved exactly like "Approve" once while
telling the user otherwise. Grant persistence itself is deferred until
there is a sound notion of grant keys (executeCommand parsing is not
AST-based), and auto-permission mode covers the fatigue meanwhile.
The dropdown now renders only when the caller wires the scope handlers.
The legacy code-mode chat keeps them (runs:authorizePermission does
persist grants there); the turns-path call sites stop passing them and
drop the dead scope plumbing from their handlers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>