Skip to content

feat(mcp): agents_presets add/edit/remove - #7274

Open
owieschon wants to merge 3929 commits into
ParadevAI:mainfrom
owieschon:feat/mcp-agents-presets-add-edit-remove
Open

owieschon wants to merge 3929 commits into
ParadevAI:mainfrom
owieschon:feat/mcp-agents-presets-add-edit-remove

Conversation

@owieschon

@owieschon owieschon commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

What & why

I could list and launch host agent presets over MCP, but I couldn't author one — still Settings → Agents by hand. That gap hits anyone driving agents from MCP (and anyone stacking on #7269 CLI presets): you can run a preset you can't create remotely.

This closes it with agents_presets_add|edit|remove on the same settings.agentConfigs.* / hostAgentConfigs path as the CLI. agents_create stays launch; CRUD is agents_presets_*. Verb edit matches CLI (host procedure still update). Launch shape: structured command+args primary, or launchCommand (tokenized); dual-reject. tokenizeAgentCommand from @superset/shared. Out of scope: reorder/restoreDefault/iconId/resumeArgs/forkArgs/SDK.

Also carries the #7276 agent-setup Test isolation cherry-pick so owned Test means what it says on this tip.

How I tested it

Tip: 19387b99c644d81d85864acca2a560b28d8ad1f9 (= dfe48dbdf + #7276 isolation cherry-pick).

  • bun test in packages/mcp: 20/20 (mock hostServiceCall — structured/launchCommand add, dual-reject, empty binary/patch, pair replace, remove id echo). Typecheck (mcp + host-service), shared suite, biome, sherif.
  • Tip-native live receipt PASS 12/12 @ tip above: real MCP Client → handlers → relay → host 447e38a1…; add/edit/remove round-trip; dual-shape + empty-patch reject; cleaned after.
  • Owned fork CI @ exact tip, Test SUCCESS: https://github.com/owieschon/superset/actions/runs/34178960314. Lint/Typecheck/Sherif/Build/Build CLI/Version Sync/Vale green. Neon/translations = secret allowlist.
  • Upstream Actions rollup is thin (CodeRabbit) — environment fact, not claimed as green.

Checklist

  • MCP-only (+ trivial tokenizeAgentCommand export in @superset/shared); no CLI / host-service / dock / SDK CRUD
  • Same host procedures; no second store
  • Unit tests + typecheck/lint/sherif
  • Tip-native live-host receipt PASS @ tip SHA above
  • Fork CI exact-commit observed

Approach issue: #7370

Kitenite and others added 30 commits August 21, 2026 23:28
…ymmetric width resize (ParadevAI#6746)

* feat(desktop): grow the new-workspace box with content and add symmetric width resize

The prompt editor in both create surfaces was capped at 200px, so longer
prompts scrolled almost immediately — the top gripe with the modal. The
editor now grows with content (modal: up to the dialog's min(80vh,720px)
cap, top-anchored so it expands downward; screen: up to min(50vh,600px))
before scrolling internally.

Both surfaces also get invisible drag handles on their vertical edges:
dragging either edge resizes the centered box symmetrically (2px width
per 1px of travel so the edge tracks the cursor), double-click resets,
and the chosen width persists per surface in a fixed-size localStorage
singleton (registered in the persisted-key allowlist).

* fix(desktop): keyboard resize, flex-sized editor, and no width transition on the new-workspace modal

- Arrow keys on the focused resize handles step the width (16px, 64px
  with Shift) so the focusable separators actually do something.
- The modal editor now sizes through a min-h-0 flex chain instead of a
  fixed calc cap, so linked-issue pills and attachments take space from
  the editor rather than pushing the dialog into a second scrollbar.
- transition-none on the DialogContent: the base duration-200 animated
  width, which made drags lag the cursor and keyboard steps read
  mid-transition values.

* refactor(desktop): scope the new-workspace growth + resize to the screen only

Drop the modal-side changes (top-anchoring, flex-chain editor sizing,
transition-none, modal width) so the experiment's control arm stays
untouched; the screen keeps the growing composer and symmetric resize.
…adevAI#6751)

* fix(desktop): resolve the dev app's workspace title from host.db

The dev-only "Superset (<name>)" label, window title, and predev
CFBundleName were looked up in v1 local.db, which has had no new
workspace rows since May. v2 workspaces and their AI/manual renames
live only in ~/.superset/host/<org>/host.db, so every v2-created
worktree fell back to its random folder name.

Read host.db first (keyed by worktree_path), then the existing v1
lookups. Folder name stays the identity for the data dir, protocol
scheme, and Neon branch; only the display title is read live, so a
later rename needs no setup re-run.

* fix(desktop): tolerate an unreadable host root when resolving the dev title

* test(desktop): make the unreadable-host-root case deterministic

* test(desktop): drop unused chmodSync import
…adevAI#5337)

VS Code–style multiple windows, each bound to its own organization. Open a
second window (File → New Window), switch it to a different org, and work on two
platforms side by side; switching one window never affects another. Each
window's title shows its org, tab/pane layouts are per-window, and open windows
restore on relaunch. Also allows the org header through API CORS so a switched
window's cloud reads resolve.

Verified end-to-end over CDP: per-window org isolation (server-scoped),
same-org window independence, and restore-on-relaunch.

Co-authored-by: Ash Brener <ashley@starlogik.com>
…her section (ParadevAI#6754)

The Cloud header already toggled the shared sidebar-sections-collapse
store, but the section never read the flag back, so the chevron turned
while the rows stayed put. Gate the rows on it the way Pinned and
Sessions do.
…meter (ParadevAI#6756)

`provider <> ALL(${PAYLOAD_READBACK_PROVIDERS})` sends the JS array as a
single parameter, which Postgres receives as a record and refuses to cast
to text[]. Every run since deploy has failed on it — 811 errors in 24h,
so the job has never pruned a row.

Built as an explicit list instead, the same way the active-subscription
statuses are handled in load-custom-session-data. Verified against a copy
of production: the statement now plans as an index scan on
automation_events_prunable_idx and executes in 50ms.

It prunes nothing today, which is correct rather than broken:
automation_events was created on 2026-08-15 by migration 0071, so no row
is yet older than the 7-day window. It has work from tomorrow.

Worth recording why this reached production: I hit the identical array
binding failure in a throwaway script earlier the same day and fixed it
there, but shipped this route without ever executing it against a
database. The partitioning work beside it was validated end to end; this
was not.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
…vAI#6749)

ingest.webhook_events is 532GB of a 580GB database, 424GB of it raw
provider bodies in TOAST. Nulling them reclaims nothing — dead TOAST only
comes back on a rewrite — so retention by UPDATE caps growth but can never
shrink the table. Partitioning by day makes retention a DROP, which
returns the space immediately.

The blocker was the dedup index. A unique index on a partitioned table has
to include the partition key, and adding received_at to (provider,
event_id) silently disables dedup: a redelivery arrives with a different
timestamp, so it no longer conflicts. Verified on a scratch database — the
same event delivered twice a day apart lands as two rows.

So webhook_events stays unpartitioned and keeps its dedup index untouched;
only the bodies move. recordWebhookDelivery writes both tables in one
statement, so the hottest write path still costs a single round trip and
the two cannot diverge. Its final SELECT reads from the event CTE rather
than the payload insert: on a redelivery the body already exists, ON
CONFLICT DO NOTHING returns no rows, and returning from that insert would
tell the caller a good delivery had failed.

This is the expand half only. The payload column stays, because dropping
it in the same deploy would fail every insert from instances still running
the old build during the rollout — and the existing ingest pruner reads
it. New deliveries stop writing it, so it drains to legacy rows that the
pruner keeps nulling. Dropping the column, deleting that pruner and
retiring its schedule is a follow-up once every writer is on this build.

Partition maintenance runs daily rather than once at migration time: it
creates 14 days of runway and drops what has aged out. A DEFAULT partition
catches anything that slips through, because an insert with no matching
partition fails outright, and on this path that means 500s. It must stay
empty — the job logs loudly when it is not, since a partition cannot be
created over a range DEFAULT already holds rows for.

Both points raised by CodeRabbit on the first version of this PR.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
…ter (ParadevAI#6757)

* fix(api): read the DEFAULT partition count before maintenance, not after

maintain_webhook_payload_partitions() throws when webhook_payloads_default
holds rows for a day it is trying to create — "updated partition constraint
for default partition would be violated by some row". Reading the DEFAULT
count after the call therefore skipped it in exactly the case it exists to
explain, leaving an opaque 500 instead of the row count and the reason.

Counted first now, and returned alongside the error so a failing run still
says what to drain.

Reproduced on a copy of production: drop a near-future partition, let a
delivery land in DEFAULT, then run maintenance — it fails at the CREATE.
Drain-and-reattach in one transaction recovers it and maintenance runs
clean afterwards.

Raised by CodeRabbit on ParadevAI#6749; merged before I had read it.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V

* fix(api): guard the default-partition count and distinguish unknown from zero

The count ran outside the try, so a database failure rejected the request
before the structured { error, defaultRows } response could be returned —
the same class of gap this PR exists to close, one step earlier.

It is now guarded, and an unreadable count reports null rather than 0.
Zero reads as "the default partition is empty", which is the opposite of
what is known when the query itself failed.

Raised by CodeRabbit on this PR.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
…adevAI#6758)

The contract half of the split. Every writer is now on the build that
sends bodies to ingest.webhook_payloads, so the column holds only rows
received before that deploy and nothing reads it back.

The ingest pruner goes with it. Its whole job was nulling this column to
cap growth; retention is a DROP of a day partition now, which returns the
space instead of leaving dead tuples behind. Its QStash schedule is
removed ahead of this merge, so nothing calls a route whose query
references a column that is going away.

Dropping the column does not shrink anything: verified on a copy of
production, the ALTER takes 50ms and the table stays at 532GB, because
DROP COLUMN is catalog-only and dead TOAST is only reclaimed by a rewrite.
That rewrite is the us-east-1 restore. What this changes is that the 424GB
stops being carried forward — the restore no longer has a column to bring
across.

Also verified on that copy: the dedup upsert on (provider, event_id) still
works with the column gone.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
* feat(marketplace): add Tokyo Night Blackout theme

Tokyo Night variant with the main content area and terminal forced to
pure black for OLED displays, sidebar left at Tokyo Night's original
navy tones. Colors cross-checked against Edmund Yong's community
"Tokyo Night Blackout" VS Code theme for the black-background palette.

* fix(marketplace): align Tokyo Night Blackout with stock palette

Rebuild the theme from Superset's actual tokyo-night.json instead of
an external VS Code extension source, so every color matches stock
Tokyo Night except the fields the black main-area concept actually
requires. Also moves the adaptation/attribution note out of the
author field and into description, per review feedback.

* fix(marketplace): align listing author with theme JSON

Applies cubic's suggestion so the marketplace page shows the same
attribution as the downloadable file.
…g link, add release runbook (ParadevAI#6760)

* chore(mobile): App Store listing and review notes aligned with ASC, add release runbook

store.config.js now mirrors what is live in App Store Connect after the
4.3(a) rejection on build 13: Superset-first title and subtitle, description
that names the desktop app and says nothing runs on the phone, promo text,
and App Review notes split into headed sections (how to review, payments,
sign-in, deletion, code execution, originality). Optional
APP_REVIEW_VIDEO_URL appends a walkthrough link.

RELEASE.md is the mobile submission runbook: versioning, eas commands,
pre-flight checklist, and what to do on rejection or stall.

Claude-Session: https://claude.ai/code/session_01SCrpWxwHCWpBAw3CijBvbu

* fix(mobile): drop the external billing link from Settings

The "Manage plan" alert opened superset.sh/settings/billing in Safari. An
in-app link to an external purchase page is a guideline 3.1.1 rejection;
the alert now only says where the plan is managed.

Claude-Session: https://claude.ai/code/session_01SCrpWxwHCWpBAw3CijBvbu
…radevAI#5799)

Tokyo Night inherited its diff-view comment color from ANSI
terminal.brightBlack (#414868, ~1.9:1) and used mutedForeground
#565f89 (~2.96:1) for muted UI text — both below WCAG AA and
near-invisible on the diff view's green/red line tints.

Pin the theme's own palette colors (no new colors introduced):
- editor.syntax.comment #9aa5ce (variable-token color; ~7:1,
  ~5:1 on added lines)
- ui.mutedForeground #565f89 -> #787c99 (the theme's own
  foreground/sideBar color; side menu 2.96 -> 4.49:1)

terminal.brightBlack is unchanged (correct ANSI dim slot).

Relates to ParadevAI#5662.


Claude-Session: https://claude.ai/code/session_01H3Jv8HZg2Rjb66jb4bVsZ1

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aradevAI#6764)

* fix(api): keep the webhook body out of a failed ingest write's error

Drizzle wraps a failed query in a DrizzleQueryError whose message and own
query/params properties are the statement text followed by every bind
parameter. recordWebhookDelivery binds the serialized webhook body, so any
failure of that write reported the full body of a third party's delivery
into the error tracker.

Rethrow at the throw site with the operation, provider and the driver's own
message, and the driver error as the cause. The statement, the single round
trip, the throw-on-failure behaviour and every caller stay as they were.

Refs API-1Z

Claude-Session: https://claude.ai/code/session_01FnF1gNfEwKxo6G9abY9DPe

* test(api): pin the driver error surviving as cause

The message assertions alone would still pass if a later change dropped the
`cause` link, and that link is what carries the driver's code and stack into
the report. Verified load-bearing: removing `{ cause }` from the rethrow fails
this test and only this test.

Raised by CodeRabbit on ParadevAI#6764.

Claude-Session: https://claude.ai/code/session_01Xub6hkHwxp1ZcCYCHZCiCY
…ent Skills guidance (ParadevAI#6762)

* docs(skills): make feedback reports concise with a titled, bullet-summary format

Title format <Surface>: <symptom or ask>, a 2-4 bullet Summary first, plain-text
sections that render in both the private email and GitHub, under-150-word target.

Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1

* docs(skills): align plugin skills with Agent Skills authoring guidance

- split browser into SKILL.md + references/browser-use.md (progressive disclosure)
- descriptions lead with user intent and trigger phrases
- allowed-tools on every plugin skill, argument-hint where missing
- drop monorepo dev-only steps from orchestrate and browser
- scripts/sweep.sh (standup) and scripts/audit.sh (10x) for the deterministic sweeps
- agents/openai.yaml for every skill, consistent cross-references, no em dashes

Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1

* test(agent-setup): validate bundled skills against the spec; keep scripts executable when provisioned

Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1

* fix(skills): address review: report type, exited terminals, mktemp, file-captured CLI output, stricter skill test

Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1

* style(agent-setup): format bundled-skills test

Claude-Session: https://claude.ai/code/session_01SudpHaYT4rQ1msxeVpWwC1
…AI#6761)

* feat(mobile): native composer skeleton, on Expo SDK 57 and an iOS 26 floor

The RN↔SwiftUI seam is what makes today's `GlassComposer` painful, not SwiftUI:
`Host matchContents` under-reports the settled glass, positional child identity
means no row may ever unmount without the field losing first responder, and two
animation systems have to be reconciled by hand. This starts replacing it with a
single native view tree.

Prep, because the composer's material needs it:
- Expo 56 → 57.0.15 (RN 0.86.2). Also fixes the Hermes V1 memory regression we
  were carrying with reanimated 4.3.1 / worklets 0.8.3.
- iOS deployment floor → 26.0, so `glassEffect` is unguarded and there is one
  visual language instead of a glass path plus a solid fallback.
- Retire the expo-router touch-cancel patch; the fix is upstream in 57.0.15.
- `expo install --fix` silently downgraded `@sentry/react-native` 8.23.0 → 7.11.0
  and `@shopify/flash-list` 2.3.2 → 2.0.2 (the latter removed `stickyHeaderConfig`).
  Both restored, and the 32 packages it moved to `~` ranges re-pinned exact.

metro.config.js: uniwind and worklets Bundle Mode both alias `react-native` and
neither knows about the other, so the shim's own import landed back in uniwind
and startup died in infinite `get NativeModules` recursion. Resolve the shim's
import to the real module.

modules/composer:
- A zero-size `ExpoView` attaches the composer as a child view controller of the
  screen's own VC, so SwiftUI gets a real full-screen safe area. Keyboard
  avoidance then costs zero code — no `keyboardLayoutGuide`, no notifications —
  including a field growing to 12 lines with the keyboard up.
- Collapsed shows a read-only draft preview, expanded the real editor; they
  cross-fade rather than one field relocating, which is what keeps the text from
  sliding and removes any first-responder to lose.
- Controls sit in the bottom row of a bottom-anchored stack, so they hold
  position while everything above grows and shrinks.
- Buttons are on solid fills, not `.buttonStyle(.glass)`: Apple's guidance is one
  glass sheet per view, and nesting leaks the press state past the container.
- `backdrop: dim | passthrough` — dim owns the screen and dismisses on an outside
  tap; passthrough leaves content behind live so a transcript can be scrolled
  while typing.

Verified on iPhone 17 Pro / iOS 26.5 with 10 Maestro flows: touch passthrough
and blocking, expand, drag-to-dismiss, tap-to-dismiss, overlay lifecycle across
navigation, button events, the submit round-trip, sheet dismissal restoring the
keyboard, and scroll-through.

`composer-preview` is unlinked dev scaffolding, deleted at cutover.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* feat(mobile): attachment carousel, and stop `+` opening the keyboard behind the sheet

Carousel, per frames 6/7/9/10/11. The tray stays in React Native — it is shared
with the attachments sheet — so the composer takes a description of it as data
and reports removals and taps back out. Expanded renders a full-bleed horizontal
strip: the scroll view spans the card's width while its content carries the
inset, so a scrolled item runs to the card's edge. Collapsed keeps one mini
thumbnail plus a `+N` badge, so the pill's width is independent of the count.

Send now appears for attachments alone, not just a draft (frame 14).

Fixes `+` from the collapsed pill raising the keyboard behind the sheet.
Presenting resigns first responder and collapses the composer, and the caller
was restoring focus unconditionally — so a composer the user had left closed
came back open, briefly showing both. The composer now reports `onExpandedChange`
and the caller restores only what was actually open. `useAttachmentsSheet` grew
an `onClosed` callback for this, which also required surfacing the sheet's
existing `onDismiss` through `presentAttachmentsSheet`'s handler type; it was
being swallowed internally.

`@Field` wrappers block Equatable synthesis, so ComposerAttachment writes `==`.

11 Maestro flows pass, including the new one asserting `+` from collapsed leaves
the composer closed.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* feat(mobile): model picker with agent icon, and the header chips row

Frame 4's header row (`superset main ⌄` · `Cloud ⌄`) and the model picker,
both data-driven. The picker shows the selected agent's brand mark beside its
name, resolved the way `useAgentIconUri` already does it — SwiftUI cannot read
Metro asset references, so React Native resolves the bundled PNG to a local file
URI first.

Both report a press rather than presenting anything themselves. The first cut
made the model picker a native `Menu`, which was wrong: the real pickers are
`formSheet` routes wrapping a whole Stack, and their lists are searchable —
branches come back 50 at a time from `searchBranches`. A menu cannot search, and
holding those lists in the composer would mean shipping the data across the
boundary for no reason. So the composer displays the selection and React Native
keeps presenting the sheets it already has, exactly like `+` and the attachments
sheet.

`selectedModel` omitted hides the picker, and an empty `headerChips` hides the
row — which is how the session surface (frame 13) and the terminal differ from
home, without either needing its own component.

Preview uses mock agents until cutover; the shape is already the shape the real
data takes, so only the source changes.

12 Maestro flows pass.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* feat(mobile): match carousel geometry to the reference, animate send, add a sending state

Carousel, measured off frames 6 and 9 rather than estimated:

- Thumbnails were 56pt against the reference's ~80pt, and proportionally
  rounder (radius/size 0.18 vs 0.13).
- The remove badge hung *outside* the corner as a bare glyph. In the reference
  it is a dark translucent circle sitting fully inside the thumbnail, inset by
  roughly its own radius, over the image.
- Collapsed `+N` was a corner chip on a dark capsule; the reference centres
  plain white text on the thumbnail. Mini thumbnail 30pt → 34pt.
- Frame 6 leaves air between the strip and the first line of text.

Typing now animates: send fades in and the mic slides left to make room. Both
come out of one animation on the control row — the transition fades send, the
HStack's own layout carries the mic, so neither needs to know about the other.

New `isSending` prop for the in-flight state: send turns grey and swaps its
arrow for a turning refresh glyph, and the mic steps aside so the spinner is the
only thing moving. The caller owns the flag because only it knows when delivery
finished — the same reason it owns clearing.

Wires the model picker and header chips to the real pickers. An earlier commit
claimed this; it was not true — the preview cycled a mock list. `onModelPress`
now pushes `new-session/agent`, and the chips push `project` / `branch`. From
the preview route they present full-screen rather than as sheets, because
`presentation: "formSheet"` is declared in the `(home)` layout; at cutover the
composer lives inside that group and gets the sheet chrome.

12 Maestro flows pass.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* feat(mobile): put the native composer on the home screen

Cuts `NewChatWidget` over from `GlassComposer` to the native module. Everything
the old composer took as React children is now data: `header` and
`toolbarLeading` become `headerChips` and `selectedModel`, both carrying the
same real project, branch and agent they always did.

What went away with it: the `KeyboardAvoidingView`, the absolute-fill dimming
backdrop and its dismiss `Pressable`, the `active` state that drove them, and
the safe-area padding maths. The composer owns keyboard tracking, dimming and
dismissal now, so the widget is 100 lines shorter and holds no layout at all.

The `above` slot is deleted rather than ported. It was a `ReactNode`, nothing
passes it, and the boundary is data-only by design — the moment a node crosses
it the seam artifacts come back.

Carousel geometry, measured off frames 6 and 9 rather than estimated: corners
less round (radius/size 0.14 → 0.11), and the remove badge is solid rather than
translucent. Thumbnails get a gradient hairline and a soft shadow to lift them
off the glass — there is no dedicated iOS API for that, and a *flat* stroke is
the wrong answer because it disappears wherever the content behind is light.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* feat(mobile): dictation, and fade expanded content out early on collapse

Dictation. The state machine stays in React Native — `useVoiceDictation` owns
the recognizer, the permission prompt and the append semantics, and it is
already proven — so the composer renders the state and reports the two presses
that drive it. Recording swaps the mic for a pill (stop square, elapsed clock,
level meter); finalizing shows the spinner; send stays out of the row while
either runs, so nothing competes with the pill.

The clock is `Text(timerInterval:)`, which iOS redraws itself. The old
`RecordingPill` ticked a `@State` date once a second purely to redraw one label.

Dictation appends rather than replaces, and the append happens natively via a
new `appendDraft`: the draft lives in the composer, so having React Native do
the join would mean mirroring every keystroke back across the bridge just to
read the base at settle time.

Collapse: header chips, carousel and editor now leave on their own fast curve
rather than the card's. Fading them over the full 0.3s left them legible almost
all the way down, so they read as being inside the collapsed pill.

Also from review: the composer is 12pt from the screen edge rather than 16pt
(measured off frame 1), and the send arrow is the theme's ink rather than pure
black — against a near-black background pure black reads as a hole punched
through the button instead of a mark drawn on it.

Growth is bounded by `lineLimit` alone. A `.frame(maxHeight:)` is the wrong tool
and was briefly the wrong answer here: a max height makes the field expand to
fill it rather than hug its content, which left an empty card the size of the
cap.

11 Maestro flows pass.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* feat(mobile): native dictation, image viewer, and file previews in the composer

Dictation runs on this side of the bridge now. It began as a prop mirror of
`useVoiceDictation`, and the level meter is what settled it: the recogniser's
volume was crossing the bridge ten times a second for the length of a recording
purely to animate five dots. `expo-speech-recognition` is itself a wrapper over
`SFSpeechRecognizer`, so owning it here is less indirection rather than more.
The state machine is carried over intact — continuous with final results only,
a 15s finalize backstop, the recogniser's own task end as authoritative,
append-never-replace — and so is the level curve, peak amplitude with a -60 dB
floor and a 1.5x gain. An RMS curve was tried first and pins the bars near full,
because RMS runs 10-15 dB under peak for speech.

Starting a recording session costs the app first responder, which the composer
read as a dismissal and closed underneath its own recording pill. It now holds
itself open for anything it put on screen, and `preparing` exists so it knows
dictation has begun from the instant of the press rather than after an async
permission round trip.

Tapping an image opens it full screen with pinch, pan and double-tap zoom on a
`UIScrollView` — SwiftUI has no zoom API, and hand-rolled magnification misses
rubber-banding and centring. The composer presents it rather than reporting the
tap out: it already holds the URI, and a React Native screen over a SwiftUI
first responder is the arrangement this rewrite exists to remove.
`onAttachmentPress` now fires only for non-images.

File attachments get frame 10's wider card instead of an 80pt square, with a
real `QLThumbnailGenerator` preview covering its tile. Every document draws the
same glyph, so without a preview and a name the tray is a row of identical grey
squares.

The card also animates its own growth. iOS does not do this for us: SwiftUI
resizes the instant content changes unless the change sits inside a transaction.
The attachment strip is keyed with `.animation(_:value:)`; the editor's growth
had to be wrapped in `withAnimation` inside its binding's setter, because a
vertical `TextField` resizes through its UIKit text layout and lands outside the
transaction an ancestor opens. Measured on 60fps captures: four lines to five
snapped in a single frame before, and eases over six now.

Smaller, all measured off the reference frames rather than eyeballed: busy
states use the stock `ProgressView` rather than a hand-rolled spinning arrow;
send drops to the ordinary control fill while in flight; the project chip leads
with its logo and reads at full foreground while the branch stays a step back;
the remove badge gets the cards' shadow.

Verified on an iPhone 17 Pro / iOS 26.5 simulator against production: file and
photo attachment round trips, the image viewer, the growth curves. Dictation
still owes a real-device pass — the simulator has no speech recognition, so the
recogniser errors within milliseconds of `engine.start()`.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): drive every composer animation from the mutation, not the view

`.animation(_:value:)` animates only the subtree it is attached to. The
composer's surface is positioned by its parent, so a modifier on the surface
never reached the frame change that matters: the card snapped to its new height
while the rows animated to new positions inside it, which is why removing an
attachment slid the input and buttons around. The same split put the control
row's horizontal layout on its own curve while the card's transaction drove the
vertical one — one control on two curves is a control moving along an arc.

So there is now one rule, and no `.animation(_:value:)` left on the surface or
the row: whoever makes the change opens the transaction. Three named curves say
what kind of change it was — `growth` for content arriving or leaving,
`typingGrowth` for text wrapping, `controlSwap` for controls trading places —
and every mutation site uses one: the module's prop setters, `setDraft`, and
`ComposerDictation.setState`.

Also gives the remove badge a real hit target. It keeps its 17pt mark, which is
what the reference draws, but carries 9pt of transparent padding with the outer
inset reduced to match, so the circle stays where frames 6 and 9 put it. 17pt is
under half Apple's 44pt minimum; aiming at it repeatedly opened the image viewer
instead of removing the attachment.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): fade send in place instead of letting it ride the card's geometry

The control row is a `.geometryGroup()` now. Without it the row's children
inherit the card's *interpolating* geometry while it resizes, so a control that
arrives mid-resize — send, the moment a draft exists — animates in from where
the row used to be rather than fading in where it belongs.

Measured on 60fps captures with the keyboard settled: send holds x=1097.5,
y=1520.8 from its first visible frame and only its opacity ramps, while the mic
slides 1015 to 977 with its y fixed. Pure fade and pure translation, which is
the behaviour this was always meant to have.

Worth recording for the next motion bug: while the keyboard is rising the whole
card translates about 169pt at constant height, and anything appearing during
that ride looks like it is sliding. Two reports chased here turned out to be
that rather than the composer.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): guard dictation against a busy microphone, drop the preview harness

`installTap` raises an Objective-C exception — not a Swift error, so there is
nothing to catch — when handed a degenerate format, which is what the input node
reports while something else holds the microphone: a phone call, another
recording app. Checking the format first turns a crash into the ordinary failure
path, where the composer settles back to idle and surfaces the error. Worth
closing before shipping a path a simulator cannot execute.

Also deletes the `composer-preview` harness. It existed to exercise states the
home screen could not reach on demand, and every one of them is now reachable
there.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* refactor(mobile): rehome the attachments sheet hook, stop inventing a branch name

`useAttachmentsSheet` lived inside the `GlassComposer` folder while the native
composer imported it from there, so deleting the old composer would have taken
the new one's attachment sheet with it. Both callers now reach it at
`screens/(authenticated)/hooks/`, which is their highest shared parent.

The branch chip fell back to the literal string "default" while the branch list
was loading. That reads as a branch name and is not one — it also reached the
create interstitial's subtitle as `project · default`. The label is null until
there is a real branch to show: the chip is absent rather than wrong, and the
subtitle drops the separator. Nothing changes about the create itself, which
sends `baseBranch` and lets the host pick its own default when that is absent.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): use the SDK 57 Babel preset, and drop the unclearable target chip

`babel.config.js` resolves the bare `babel-preset-expo`, which reaches the app's
own direct dependency — still pinned at 56.0.11 — rather than the 57.0.7 that
expo@57.0.15 brings. So every bundle since the SDK bump was transformed by the
SDK 56 preset against React Native 0.86.2, with `@react-native/babel-plugin-codegen`
0.85.3 and a Hermes parser three minors behind. It built and ran, which is why
this went unnoticed. Pinned to 57.0.7; the lockfile loses the duplicate entry and
everything now shares expo's own copy. Rebundled with `--clear` and booted to
confirm.

`fixedTarget` pins the composer to one workspace and is not the user's to clear,
but it still rendered a "New agent in …" chip whose press only clears
`storeTarget` — so the chip stayed put. A fixed target now gets no chips at all,
which is what the prop documents. Latent today: nothing passes `fixedTarget`.

Also moves `Composer` out of the package barrel into its own folder, per the
repo's one-folder-per-component rule.

Found by CodeRabbit on ParadevAI#6761.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* chore: exempt the Expo SDK 57 pins from the release-age gate

Expo 57 landed inside the 3-day `minimumReleaseAge` window, so a fresh
`bun install` could not resolve it — eleven packages failed at once. CI never
saw this because it installs frozen from the lockfile, which is exactly the
shape of problem that only bites whoever clones next.

`minimumReleaseAgeExcludes` covers just those pins. They are exact versions, so
the list can only ever admit the ones we already chose. Verified with a full
`bun install --force` and no bypass flag: it resolves, and the lockfile it
produces is byte-identical to the committed one.

The list can go once the pins age past the window.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* Revert "move Composer into its own folder": it crashes tsc

TypeScript 6.0.3 panics with `Debug Failure` inside `resolveExternalModule`
while checking an import, once `@superset/composer` resolves through
`src/index.ts` -> `src/Composer/index.ts` -> `src/Composer/Composer.tsx`.
Bisected: the layout is the only difference, and pointing the outer barrel
straight at the file does not help — the crash needs the folder gone.

The restructure was a file-convention nicety suggested in review. It is not
worth crashing the typechecker for, so the module keeps its single
`src/index.tsx` entry.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie
…ons test (ParadevAI#6770)

apps/api had four test files and no `test` script, so `turbo test` skipped the
package entirely and CI had never run any of them. One had been failing on main
unnoticed as a result.

The failure was mock drift, not a leak across files: the test mocked
`@superset/db/schema` with only `userIdentities`, while the route imports
`integrationConnections` from it as well, so the module failed to load at all
(`SyntaxError: Export named 'integrationConnections' not found`). The mock also
lacked `userIdentities.organizationId`, which the route uses.

Dropping the schema mock is the fix rather than extending it: the schema module
is pure table definitions with no connection or env access, and the db client is
already mocked separately, so there was nothing for that mock to protect against
and it could only drift again.

That exposed the same drift one layer down — the db mock had no
`query.integrationConnections`, which the route reads to resolve the Slack
workspace to an organization before it looks at any action. Returning a
connection is what lets the assertions reach the action handling; without it the
route returns early and the tests would pass for the wrong reason.

Verification:
- `cd apps/api && bun run test` -> 21 pass, 0 fail across 4 files (was: module
  would not load, 1 fail + 1 error).
- `bunx turbo test --filter=@superset/api` -> runs the package, 21 pass. Turbo
  skipped it before. The "no output files" warning is pre-existing and emitted
  for every package with no coverage output, confirmed against port-scanner.
- Checked the repaired tests are not vacuous: removing the value-type check from
  isSlackInteractionAction fails "ignores model select actions with malformed
  selected option values", and only that test.
- `cd apps/api && bun run typecheck` clean; `bunx biome check` on both files clean.

This also puts ParadevAI#6764's recordWebhookDelivery leak test under CI, which it was
not before.

Claude-Session: https://claude.ai/code/session_01Xub6hkHwxp1ZcCYCHZCiCY
…aradevAI#6771)

`git/removeWorktree` blows its two-minute budget in the field
(HOST-SERVICE-17, HOST-SERVICE-47) and the only thing the report says is
that 120000ms elapsed. The task's three steps — resolving the worktree
path through a blocking realpathSync, the `worktree remove` subprocess,
and the `worktree list` read after it — stall for entirely unrelated
reasons, and nothing distinguished them.

The budget is a timer in the parent while the steps run in the worker, so
at the moment the parent gives up it knows only the elapsed time, and it
retires the worker immediately afterwards. The worker therefore announces
each step as it starts; the parent keeps the last announcement and appends
` in phase "<phase>"` to the timeout error, which is interpolated verbatim
into the TRPCError that reaches Sentry. A task that reports no phase
produces the same message as before.

This changes nothing else: not the budget, not retry behaviour, not what
the user sees, not the deletion logic. We still do not know why it hangs —
that is what this is for.

Refs HOST-SERVICE-47
Refs HOST-SERVICE-17

Claude-Session: https://claude.ai/code/session_01BrNKdKTC54LnA9jdpDDtA8
* feat(ingest): bound row growth on both event logs

Bodies are bounded — webhook bodies by dropping day partitions, automation
payloads by the pruner — but nothing ever removed the rows. webhook_events
is at 105.7M and takes 1.16M/day, automation_events another 758K/day, both
with no ceiling.

Deleting rather than partitioning, because the dedup indexes have to stay
where they are: a unique index on a partitioned table must contain the
partition key, and adding received_at to (provider, event_id) would mean a
redelivery no longer conflicts and dedup silently stops working.

Deletion is cheap here in a way it would not have been a week ago. With
the bodies moved out these rows are ~350 bytes and touch no TOAST, so a
batch leaves very little for autovacuum. Measured on a copy of production:
5,000 rows in 403ms, planning as an index scan then a TID scan.

Retention is 30 days, set by idempotency rather than storage — the unique
indexes are what stop a redelivered event running twice, and a row that no
longer exists cannot dedupe. Providers redeliver within hours to days, so
thirty is well past any real replay window. One constant to change.

automation_events gets a plain received_at index; its three existing ones
are either partial or lead with organization_id, so none can serve an
age-range scan. The build takes ~20s on 4.5M rows and holds a write lock
for that time.

Paced at 50k rows per table per run on a 5-minute schedule, the 65.2M-row
backlog drains in ~4.5 days and then holds steady.

webhook_payloads needs no equivalent: its partitions are dropped at seven
days, so a body is always gone well before the event row pointing at it.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V

* feat(ingest): bound row growth on webhook_events

webhook_events is at 105.7M rows taking 1.16M/day with nothing deleting
them. Bodies were bounded when they moved to day partitions; the rows were
not.

Deleting rather than partitioning, because the dedup index has to stay on
(provider, event_id): a unique index on a partitioned table must contain
the partition key, and adding received_at would mean a redelivery no
longer conflicts and dedup silently stops working.

Deletion is cheap now in a way it would not have been a week ago. With the
bodies moved out these rows are ~350 bytes and touch no TOAST, so a batch
leaves very little for autovacuum. Measured on a copy of production: 5,000
rows in 403ms, planning as an index scan then a TID scan, against the
webhook_events_received_at_idx that already exists. Paced at 50k per run
on a 5-minute schedule the 65.2M-row backlog drains in ~4.5 days.

Retention is 30 days, set by idempotency rather than storage — the unique
index is what stops a redelivered event running twice, and a row that no
longer exists cannot dedupe.

automation_events is left out on purpose. Bounding it needs two index
builds that take write locks on live ingest tables, and it cannot have a
row older than thirty days until mid-September, by which point the
us-east-1 restore has rebuilt both tables with those indexes created on
the way in. Adding it here would buy nothing and cost two locks.

The per-target time budget is sliced rather than shared even at one
target, so the second cannot be starved when it joins.

CodeRabbit raised all three: the missing automation_runs.event_id index,
the shared deadline, and the blocking index build. Dropping that half of
the change resolves the first and third outright.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
…e's chrome (ParadevAI#6773)

* fix(mobile): size the composer chrome to the reference and tighten the header gap

Measured a prod screenshot against frame 4, both normalised to 3x on a 440pt
screen and anchored to each card's own top edge:

  gap, header ink -> first draft ink   ours 30.3pt   mock 19.0pt
  header text ink height               ours 15.3pt   mock 12.7pt

Two causes. The chips and the model picker inherited the 17pt default body
because nothing set a font, where the reference's chrome is ~14pt; and the
header carried an 8pt bottom inset on top of that. The editor was already
right — its 17pt matches the mock — so only the chrome moved.

Now 18.3pt and 12.3pt against those same references. The remaining inset is 5pt
rather than 8: dropping the chrome to 14pt took ~11pt out of the line box on its
own, so the padding had to come down with it rather than stay put.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* docs(mobile): note the module-scheme build loop

Rebuilding the whole app to change one Swift file costs minutes a cycle;
CocoaPods generates a scheme per pod and building that one is ~6s.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* feat(mobile): put the native composer on the terminal screen

Two surface differences from home, both props rather than children.

The backdrop is `passthrough`: the transcript stays live and scrollable while
the keyboard is up, because reading scrollback while typing the next command is
the point of this screen. Home keeps `dim`, where the composer owns the screen
and the outside tap dismisses it.

The quick keys move into the composer's own view tree. They have to: as React
Native siblings the gap to the pill was a hardcoded guess at a height the host
under-reported, so it drifted whenever the pill grew and animated on its own
curve — the previous `QuickKeysRow` says exactly this in its own doc comment,
which is why it had already been pushed inside the SwiftUI host once. Against an
overlay React Native cannot measure at all, so a sibling was never an option.

They cross as data — `{ id, label?, symbol? }`. The bytes each key writes into
the PTY, and whether it submits instead, stay with the terminal that owns them;
the composer draws a mark and reports an id. Select mode then needs no native
code at all, just a different array.

Styled with the system's own `.glass` button inside a `GlassEffectContainer`
rather than a hand-rolled fill. The earlier lesson about not nesting glass
applies to controls sitting *on* the card's sheet; this strip floats over the
terminal as a surface of its own, which is the case the style exists for — and
it brings press feedback, hit slop, Increase Contrast and Reduce Transparency
that a fixed opacity has to fake.

Also adds `showAttachments` (a plain shell would try to execute an attachment
path) and `autocapitalization` (a shell command is not a sentence).

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): let the terminal inset for the composer, and drop the glass chips

Three things the terminal surfaced that the home screen never could.

The composer draws in an overlay and takes no React Native layout space, so the
screen's `onLayout` measured roughly nothing and the terminal's own prompt sat
behind the card. The composer now reports the room it occupies — card, quick
keys, and the gaps — and the screen insets by that. It reports its *size*, not
its position: the keyboard moves the cluster without resizing it, so the value
only changes when the composer genuinely grows, and the keyboard stays the
screen's business, where it already arrives with a duration and a curve worth
animating to.

The pull-requests button moves above the composer for the same reason; it used
to share a wrapper with a composer that no longer occupies it.

And the quick keys go back to `.bordered`. Glass is a material for sampling rich
content behind it, and the content here is a mostly-black scrollback — it read
as a smear. The bordered chip is what the row had before and still brings the
system's press feedback, hit slop and contrast handling.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): hide the model picker when there is nothing selected

Found by driving a real terminal session: with no agent to pick, the picker
still drew its chevron beside an empty label — an orphan control in the corner
of the card pointing at a menu that does not exist. The home surface always has
a model, so it never showed there.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): gate terminal attachments on the session, not just the button

The attachment tray is shared across tabs, so files attached in an agent session
are still there after switching to a plain shell. `allowAttachments` hid the `+`
button but not the submit path, so those paths would still be appended to the
message — and a plain shell tries to *execute* them, which is the exact failure
the flag exists to prevent.

Also stops the pull-requests button floating: it positions itself above the
composer's reported height, which goes stale the moment the composer is hidden.

Found by CodeRabbit on ParadevAI#6773.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie
)

`ResizablePanel` reads `defaultSize` only when the group mounts, so
equalizing a v2 tab updated the layout in the store while the panels
stayed where they were last dragged — the ⌘⇧0 hotkey and the "Equalize
Pane Splits" context-menu item both looked like no-ops. Push the store's
split percentage through the group's imperative handle whenever it
changes.

Claude-Session: https://claude.ai/code/session_01Td2NUVeMANb1qfap6xtzxt
…aradevAI#6778)

* feat(mobile): paste images and files into the composer, delete GlassComposer

**Paste**, without touching the editor. `ComposerRootView` is byte-identical to
what shipped.

SwiftUI has no paste hook on iOS: `onPasteCommand` and `pasteDestination` are
both `@available(iOS, unavailable)`, and `PasteButton` is a button rather than
the edit menu. UIKit's route is a paste configuration, and the responder chain
is what makes it cheap — the text field keeps taking strings exactly as it does
today, and anything it cannot represent walks up to `ComposerPassthroughView`,
which the composer already owns for hit-testing. Thirty lines on a view that
already exists, and no editor of our own to maintain.

Providers resolve to files on disk because the tray takes URIs, the same shape
the pickers produce: a file copied in Files arrives as a URL and is copied out
of its temporary home, an image copied from Photos or a browser arrives as bytes
and keeps its original encoding rather than being re-rendered.

**Deletion.** `GlassComposer` has no callers left, so it and its voice stack go,
and `expo-speech-recognition` with them — 13 files.

One trap there worth naming: that package's config plugin was what injected
`NSSpeechRecognitionUsageDescription`. Removing the plugin without replacing the
key would have left native dictation terminating the app on its first
authorization request. The string now lives in `ios.infoPlist` directly, and a
prebuild confirms all four usage descriptions still land in the generated
Info.plist. The microphone one was never at risk — `expo-image-picker` supplies
it.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): clear the attachment tray on send, not just the draft

Attachments survived every successful send and went out again with the next
message.

The native composer's `clear()` reaches its own text and nothing else — the
tray belongs to React Native. The React Native form it replaced had a `clearAll`
that cleared both, and cutting the composer over to a direct `onSubmit` callback
left only half of that behind. Shipping since the home screen moved over.

Both surfaces now clear the two together, and still only on success: a failed
send keeps the draft, so it has to keep the attachments too or the retry sends
something different from what failed.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* chore(mobile): drop expo-speech-recognition from the lockfile

Deleting GlassComposer took the last consumer with it, but the lockfile still
carried the package. Also ignores the pasted-attachment scratch directory the
workspace writes into.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* feat(mobile): keep a composer draft per surface across navigation

Leaving the home screen or a workspace threw away whatever was typed there.
Text and attachments now survive navigation, scoped per surface — the home
screen, and one draft per workspace — and are restored when you come back.

Where the state lives was the real question. The tray was already React
Native's and cannot move: the pickers, the attachments sheet, paste and the
terminal's uploader all mutate it, and the composer only renders a mirror.
Putting the text in Swift beside it would make a draft two halves in two
languages with two lifetimes to keep in sync, so both halves live in React
Native now, in one in-memory store keyed by surface.

The composer stays uncontrolled, which is what makes this safe. It reports
`onChangeText` outward the way it already reports `onHeightChange`, and takes
its text back exactly once, through `restoreDraft` at mount. There is
deliberately no `value` prop: a controlled field resizes outside the
transaction that reveals send, which is the split-brain the motion rule
exists to prevent. Nothing subscribes to the text either — a caller reads it
once to hand it back — so typing still causes no React Native render at all.

Not persisted across launches. Attachment URIs point into the app's cache,
which the OS may evict between launches; within one launch it cannot, and
that is exactly this store's lifetime.

Two things fall out of scoping it. The single app-wide tray is gone, so an
image attached on the home screen is no longer sitting in every workspace —
`PromptInputProvider` was only there to hold it and is no longer mounted. And
a plain shell now hides the tray rather than showing chips its submit will
silently drop, since a workspace's draft is shared across its terminals.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): make the saved draft actually reach the composer

Two bugs, both only visible by driving the simulator — the code read fine.

The outward event never arrived. Named `onChangeText`, it collided with React
Native's own TextInput event convention and was swallowed before reaching JS,
so nothing was ever saved and every restore read an empty store. Renamed to
`onDraftChange`, which is also the more accurate name: this reports a draft,
not a text field's value.

Restoring through the ref raced the view's own creation. At mount the call
landed before there was anything to receive it — a 400ms delay made it work,
which is the shape of a race, not a fix. The saved draft is an `initialDraft`
prop now, delivered as part of setting the view up rather than chasing it, and
applied once so later deliveries cannot clobber live typing. `restoreDraft`
is gone from the ref; nothing needs it.

Verified on the simulator: a draft typed on the home screen survives opening a
workspace and coming back; a draft typed in a workspace comes back on return;
neither leaks into the other; an image attached on the home screen no longer
shows up in a workspace tray; and a send clears text and tray together.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie

* fix(mobile): don't discard a failed paste copy, or a tray a shell never sent

Both from CodeRabbit on ParadevAI#6778, both real.

A failed `copyItem` was swallowed by `try?` and the paste still resolved, handing
back an item pointing at a file that was never written — the tray would only
find out at upload or preview. `loadImage` ten lines below already guards its
write the same way; now `loadFile` does too.

And a plain shell was deleting attachments it never sent. Its submit omits them
by design, but the clear afterwards wiped the tray anyway — and because a draft
belongs to the workspace rather than to one terminal, an image attached in an
agent session vanished when a command ran in a shell beside it. The tray is now
cleared only when the send could actually carry it; the text always is.

Claude-Session: https://claude.ai/code/session_01Qriwo2mN6xcnE6A147TXie
…evAI#6777)

Open a file editor next to another pane and the divider beside it grew to
3px. The pane box drew a 2px border that turned from transparent to
border-coloured when the pane took focus, and that border sat directly
against the 1px line its neighbour already drew: the split ResizableHandle
on one side, the right sidebar's ResizablePanel border on the other.

The pane no longer draws that border. PaneHeader already dims to 60%
opacity on the panes that are not focused, so the focused pane is still
readable, and every seam is now a single 1px line.

The sidebar's top strip also stepped down from the pane tab bar. The PR
action header was h-12 against the tab bar's h-10, so the two horizontal
borders landed 9px apart at the divider. The header is now h-10, and the
sidebar tab row is pulled up one pixel so its top border shares a screen
row with the tab bar's bottom border.

Verified against a running dev build over CDP: both border bands report
[39, 40], and the two seams measure 1px where they measured 3px before.

Claude-Session: https://claude.ai/code/session_01AWpYhSNQVyCQWUfuJ4X5bP
…rminal tab (ParadevAI#6786)

* fix(mobile): refetch host agent lists on focus and land on the new terminal tab

React Query never saw app focus on native — focusManager was unwired, so no
query refetched on foregrounding and the composer's agent list stayed frozen
from app launch. Wire focusManager to AppState, and give the three host
agent-config queries one shared hook that refetches unconditionally on focus,
since agent edits happen on the desktop while this app is backgrounded.

The + sheet's dismissTo lands with the new session in ?tab= before the
terminals query has heard of it, and the pin-active effect re-asserted the old
tab in the same commit the fresh row arrived — the new session never
activated. Merge adoption and pin into one effect that adopts a ?tab= once
its row arrives, so the fresh terminal wins over the pin exactly once.

* fix(mobile): a manual tab pick consumes a pending ?tab= adoption
… a buffer (ParadevAI#6788)

decryptCookieValue's "v10" scheme check ran before its own try/catch, so a
cookie row whose encrypted_value came back as a string threw
`encryptedValue.subarray is not a function` out of mapCookieRow, killed the
read loop, and failed the whole import. Move the check inside the guard so
such a row is skipped like any other undecryptable one.

Refs DESKTOP-11J
… fs handler (ParadevAI#6789)

The rename branch of the Files tab's fs-event handler looked up the renamed
folder's handle with an unguarded `model.getItem`. Pierre's `getItem` is typed
to return null for a path it doesn't hold, but it throws when the lookup walks
through a node with no child index, so the throw escaped the workspace event
socket callback and aborted the rest of that watcher batch.

Contain the lookup: a path the model can't resolve now yields no handle, which
is the same answer as "not expanded".

Refs DESKTOP-11E
…#6790)

proper-lockfile keeps an acquired lock fresh on a timer. When another
desktop main process reclaims the entry because our refresh fell behind,
that timer's stat returns ENOENT and the library's default onCompromised
handler rethrows from inside its own setTimeout, where no caller can
catch it. Supply a handler so the lost lock is recorded instead.

The same event marks the lock released, so the finally-block release()
rejects with ERELEASED and replaces the operation's result -- reporting a
token that was written as a sign-in that failed to save. Releasing is
cleanup and says nothing about whether the write landed, so it no longer
overrides the operation's own success or failure.

Acquisition failures and write failures still reject, so a token that was
not persisted can never look persisted.

Refs DESKTOP-11M

Claude-Session: https://claude.ai/code/session_01PXMCuUAHrKLAhRWtPF1o7V
…adevAI#6793)

`normalizeEvent` stats every non-delete event to decide `isDirectory`. When
that stat fails — the ordinary case for a rename that moved the path on again
— it fell back to `state.directoryPaths.has(path)`, which answers `false` for
a path the watcher has never seen. So an uninspectable directory was emitted
as a positive "this is a file".

Report no type at all when there is nothing to report. A remembered path still
answers from the record (directory or file); only a path the watcher has never
seen now comes back with `isDirectory` absent, which `FsWatchEvent` has always
declared optional. That hands the Files tab's rename branch the `undefined` its
existing `event.isDirectory ?? oldKey?.endsWith("/")` fallback was written for,
so a renamed folder keeps the type the tree already holds instead of entering
as a file node.

Refs DESKTOP-11E

Claude-Session: https://claude.ai/code/session_01LZ4rtC7vJCrUYNTaUxqdR3
…write (ParadevAI#6792)

* test(desktop): pin that a reclaimed auth-token lock cannot crash the write

Nothing covered the path ParadevAI#6790 fixed. Deleting the onCompromised handler
from withAuthLock reintroduced a fatal, uncatchable throw on the sign-in
token write and the whole suite stayed green.

The refresh proper-lockfile arms after handing us the lock only fires on
a timer derived from `stale` -- five seconds at the current setting --
which is too long to wait for in a suite that runs in under a second.
Intercept that one scheduling call instead: remove the lock entry the way
a second desktop process reclaiming it would, then run the library's own
refresh callback. Its stat, its ENOENT handling, its compromise
bookkeeping and its onCompromised dispatch are all the real thing; only
the five-second delay is skipped, and the library is never mocked.

Refs DESKTOP-11M

Claude-Session: https://claude.ai/code/session_01H1nPckannUVVytgNdnd3YG

* test(desktop): wait for the reported lock loss instead of racing it

The compromise is reported from proper-lockfile's own stat callback, which
can land after saveToken resolves. Reading the count straight after the
write made that assertion pass or fail on scheduling; await the report
instead. A loss that is never reported now hangs to the test timeout, which
is the failure we want, and removing onCompromised still fails fast with the
library's ECOMPROMISED throw.

Claude-Session: https://claude.ai/code/session_01YXpYt5vhSMHdqFPUymBdc6

---------

Co-authored-by: Satya Patel <satya@superset.sh>
…inbound Linear events (ParadevAI#6799)

* fix(linear): make the task mirror reject stale inbound events

Groundwork for putting a queue in front of this endpoint, not a fix for
a failure anyone has reported.

A write we push to Linear returns to us as a webhook, and the mirror
applies it unconditionally. Today that is close to harmless: the echo
only overwrites if a second edit lands in the second or two before it
arrives, and it is largely self-correcting, because that second edit
pushes too and its own echo repairs the damage moments later. The case
that does not self-correct — two echoes arriving out of order, the stale
one landing last — is rare enough over direct delivery that there is no
evidence of it happening.

Both of those protections are properties of low, ordered latency, and a
gateway removes them. Queue depth widens the window from seconds to
however far behind the consumer is, minutes on a retry, and at-least-once
delivery makes reordering routine rather than exceptional. The guard has
to exist before deliveries can be delayed and reordered, not after.

Records Linear's own updatedAt on every outbound write and refuses any
inbound event that is not strictly newer, which covers the echo and the
reordering with one comparison. Checked ahead of the branchName fetch so
a suppressed event costs no Linear API call, and again in the upsert's
setWhere, because that read can go stale before the write lands and two
deliveries for one issue can race. Compared column-to-column against
excluded rather than a bound parameter, so no second timestamp conversion
can disagree with the first.

Rows predating the column have a null watermark and are always applied,
so existing tasks adopt one on their next event.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V

* feat(ingest): accept deliveries forwarded by Hookdeck

Adds verification for a Hookdeck-forwarded delivery and wires it into the
Linear webhook ahead of Linear's own check. Both paths stay live: anything
arriving without an x-hookdeck-signature verifies exactly as it did
before, so a cutover is repointing a URL and a rollback needs no deploy.

Linear is the route that needs this. Its signature covers a timestamp and
the SDK enforces Linear's ±60s replay window, while Hookdeck preserves the
original signature and timestamp when it retries — so re-checking Linear's
signature behind a queue would reject every retry, which is precisely the
delivery a gateway exists to save. Hookdeck's own signature is HMAC-SHA256
over the body alone and survives any delay. GitHub needs no equivalent
change: its signature covers no timestamp, so it already tolerates
arbitrary retry latency.

x-hookdeck-verified is checked alongside the signature and is not
redundant. The signature proves Hookdeck sent this; the flag proves
Hookdeck authenticated the provider before forwarding. Without it, a
source whose verification was never configured would hand us whatever
anyone posted to its URL, correctly signed by Hookdeck and
indistinguishable from real traffic.

The secret is optional so environments without Hookdeck keep booting, but
a signed delivery arriving somewhere it is unset is rejected rather than
trusted — the alternative is trusting a header anyone can send.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V

* ci(deploy): pass HOOKDECK_SIGNING_SECRET through to the API

GitHub Secrets are where runtime env originates in this repo — the deploy
workflows map them into the environment the Vercel CLI reads, so a
variable that is not listed here is never populated no matter what the
schema says. Without it the Hookdeck branch would be unreachable in every
environment and every delivery would quietly fall through to provider
verification, which is the failure that looks like nothing being wrong.

ci.yml needs no change: lint, typecheck and test never read it, and the
schema marks it optional so a build without it still succeeds.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V

* test(desktop): add externalUpdatedAt to the task fixture

The fixture spells out every column of TaskWithStatus, so adding one to
the schema breaks it. Caught by CI rather than locally because the root
typecheck spans 37 packages and I had only run the two I touched.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V

* fix(linear): keep the outbound watermark moving forward only

CodeRabbit caught a hole in the guard two commits back. The outbound
update wrote externalUpdatedAt unconditionally, so the watermark was not
actually monotonic and the inbound guard was only as good as the value it
compares against.

The push goes out through QStash and can be retried, so its response can
land after a webhook that already recorded something newer. Writing ours
unconditionally then drags the watermark backwards, and the next stale
delivery sails through the check that exists to stop it — the original bug,
reachable by a slightly longer route.

Gates the update on the same null-or-strictly-older predicate the inbound
upsert uses. Skipping the write costs nothing: the webhook that overtook us
already set lastSyncedAt and cleared syncError on its way past.

Left the create path alone. A task being pushed to Linear for the first
time has no watermark, so every write there is an increase, and gating it
would risk dropping the external id linkage for no gain.

Claude-Session: https://claude.ai/code/session_014D6wif8HDRCXEFf5Ku4w7V
* feat(mobile): wire every pull request card action

Every card button except Merge showed a "not available yet" alert. The host
gains markPullRequestReady, updatePullRequestBranch, reopenPullRequest and
dequeuePullRequest (GraphQL ones resolve the node id first), all mapping
GitHub refusals to non-500s through actionRejectionError — the generalized,
exported successor of mergeRejectionError, with GraphQL rejections (no HTTP
status) mapped to BAD_REQUEST.

Mobile wires the plain actions through usePullRequestActions (one at a time,
GitHub's own wording on refusal) and the "… with Agent" buttons through
useAskAgent: one tap starts a fresh agent session in the workspace with the
instruction already sent — no edit step, per the reference mocks — and lands
on its tab. Prompts name the PR and the failing checks.

* fix(mobile): fence check names in the agent prompt, guard duplicate actions

Adversarial-review follow-ups: check names ride the PR head branch, so on a
fork PR they are contributor text — they now enter the fix-checks prompt as
quoted data with control characters stripped, length capped at 80 and count
at 10. And usePullRequestActions now really is one-at-a-time: run() ignores
taps while a mutation is in flight, since busyAction re-enables the other
buttons the moment a second tap changes the variables.

* fix(mobile): latch action submission synchronously, not on a render snapshot
saddlepaddle and others added 15 commits September 1, 2026 17:23
…adevAI#7079)

Counts say a row was rewritten. They cannot say the rewritten URL renders,
which is the only thing that matters after repointing a thousand of them.
Printing a few live URLs makes a run checkable by fetching one.

Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* chore: remove Vercel Blob

Nothing reads Blob any more. The last importer was the one-off pages
migration script, which goes with it.

BLOB_READ_WRITE_TOKEN was required in two schemas — z.string() in apps/api,
.min(1) in packages/trpc — with no consumer behind it. That is the tripwire
this change exists to defuse: deleting the secret at teardown while the
schemas still demanded it would have failed API boot, and the variable would
have looked entirely innocent to whoever deleted it.

The next/image remotePatterns for the Blob host go too, in all five apps that
carried one. Where Blob was the only remote host, the images block is gone
rather than left as an empty array. Marketing keeps unavatar.io.

DO NOT MERGE until the blob-port report confirms nothing still depends on
Blob: no page version pointing at a Blob key, and no row still on a Blob URL.

Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf

* chore: remove the last Vercel Blob references outside app source

A repo-wide sweep for "blob", rather than one scoped to apps/ and packages/,
turned up three the earlier pass could not have seen.

turbo.jsonc listed BLOB_READ_WRITE_TOKEN in globalEnv, which is part of the
cache key: it would have kept hashing a variable that no longer exists.

Two prose references named Vercel Blob as the example of a place to host an
image — a desktop notices doc and a comment in the file viewer. Neither
affects behaviour, both would have sent the next reader looking for a service
we no longer use.

Everything else matching "blob" is the browser Blob API or a blob: URL in a
CSP, which are unrelated and stay. blob_pathname survives only in drizzle
snapshots 0096-0099 as history; the column is long gone and snapshots are
not edited.

Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
Clerk stopped being the auth provider in January. These three variables have
no consumer in any app, no secret or variable in GitHub, and no reference in
any workflow — but globalEnv is part of Turbo's cache key, so it kept hashing
them.

Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
The Sigma template builds its date spine from exchange_rates_from_usd,
which lands a day late, and then shifts it back another day. The series
stopped two days short of today while the subscription events behind it
were already current, so the admin MRR tile showed MRR from two days ago
— $15,845 against Stripe's $16,150 — and read as stuck rather than as
stale. The hourly refresh job was working the whole time; it was
faithfully caching a query that had thrown the last two days away.

Forward-fill the last known rates over a spine that runs to whichever of
the two sources is newer. Verified against live Sigma: the series now
reaches 2026-09-01 at $16,130. The residual gap to Stripe's dashboard is
Sigma's own snapshot lag — its newest event at the time of writing was
14:07 local.

Start the spine at the first subscription change rather than the first FX
rate. SEQUENCE caps at 10k elements and the FX table reaches back to
2010, which would have walked the query into a hard failure some years
out, over rows that are all zero MRR anyway.

Claude-Session: https://claude.ai/code/session_018FrUA5omygopkJUSLf78H3
…ParadevAI#7085)

* fix(trpc): carry the MRR series past the FX table's lag

The Sigma template builds its date spine from exchange_rates_from_usd,
which lands a day late, and then shifts it back another day. The series
stopped two days short of today while the subscription events behind it
were already current, so the admin MRR tile showed MRR from two days ago
— $15,845 against Stripe's $16,150 — and read as stuck rather than as
stale. The hourly refresh job was working the whole time; it was
faithfully caching a query that had thrown the last two days away.

Forward-fill the last known rates over a spine that runs to whichever of
the two sources is newer. Verified against live Sigma: the series now
reaches 2026-09-01 at $16,130. The residual gap to Stripe's dashboard is
Sigma's own snapshot lag — its newest event at the time of writing was
14:07 local.

Start the spine at the first subscription change rather than the first FX
rate. SEQUENCE caps at 10k elements and the FX table reaches back to
2010, which would have walked the query into a hard failure some years
out, over rows that are all zero MRR anyway.

Claude-Session: https://claude.ai/code/session_018FrUA5omygopkJUSLf78H3

* feat(admin): give the MRR tile a refresh button that breaks the cache

The tile is served from a 12h Redis entry that the hourly job keeps warm,
so there was no way to ask for a recomputation short of waiting out the
TTL — which is exactly what you want after changing the query behind it.

Adds business.refreshMrr. It kicks a Sigma run and drops the cached
entry, which is also what makes the tile's existing poll follow that run:
getMrr serves the cache before it ever looks at a pending run, so an
entry left in place would leave the new run uncollected until the next
hourly job. The entry is only dropped once there is a run to replace it
with, so a Stripe blip leaves the last good number on screen rather than
trading it for an error.

Kicking the run is all the mutation does. Sigma takes 30-60s and the tRPC
route caps at 60s, so driving it to completion server-side would be a
coin flip against the function timeout.

InsightTileFrame already rendered onRefresh/isRefreshing as a spinning
LuRefreshCw; MrrTile just never passed them. The tile now holds the
series already on screen across the "computing" state rather than
blanking for the minute Sigma takes — but only across "computing", so a
real failure still surfaces as it did before.

Claude-Session: https://claude.ai/code/session_018FrUA5omygopkJUSLf78H3
…oring and /md (ParadevAI#7083)

* fix(marketing): stop the locale proxy from swallowing /ingest, /monitoring and /md

The [lang] proxy added in ParadevAI#7002 matches every path without a dot, so three
extensionless endpoints were rewritten to /en/... and 404ed from the moment it
deployed (2026-08-29 21:46 UTC; marketing events stop at 21:49):

- /ingest/*    PostHog capture. Silent, because /ingest/static/*.js has a dot
               and slips through, so posthog-js loads and then every capture
               dies. Four days of marketing analytics lost.
- /monitoring  Sentry's tunnelRoute. Marketing error reporting equally dark.
- /md/*        Markdown twins for agents, serving an HTML 404 instead of the
               recoverable markdown 404 the route exists to return.

apps/web and apps/api already exclude ingest and monitoring; marketing's
matcher was written fresh instead of copying them. Named explicitly now.

/md moves under [lang] rather than being exempted: the rewrite is then what
makes it work, and the twins become localized -- /ja/md/page/pricing serves
Japanese, which it never did. The route seeds i18n through initServerI18n like
every other [lang] entry, and canonicals carry the locale.

The deploy gate stayed green throughout: it fails on 4xx, but only for routes
it lists, and it listed only pages. Adds a non-localized pass over the
non-page endpoints.

Claude-Session: https://claude.ai/code/session_01SduXRDYRusSKRKs6sFg31t

* fix(marketing): satisfy biome import ordering

Claude-Session: https://claude.ai/code/session_01SduXRDYRusSKRKs6sFg31t

* fix(marketing): read the md twin's locale from params, not root-params

app/i18n-server.ts resolves the locale through next/root-params, which is a
server-component API. This is a route handler, and nothing proves root-params
resolves in one — if it returned undefined the helper calls notFound() and
every markdown twin 404s, which is the bug this branch exists to fix.

[lang] is a parent segment, so the locale is already in this route's own
params. Reading it there behaves identically in a route handler and a server
component, and an unsupported segment returns the recoverable markdown 404
instead of an HTML one.

Claude-Session: https://claude.ai/code/session_01SduXRDYRusSKRKs6sFg31t
users.clerk_id and organizations.clerk_org_id are left over from Clerk, which
has not been the auth provider since January. Nothing reads or writes either
column — checked by exact column name across the repo — and the tables
themselves are still live, so this drops the columns rather than the tables.

clerk_id was notNull().unique(), which made it more than dead weight: any
future insert into the local users table would have had to invent a value for
a provider we do not use.

Migration generated by drizzle-kit, not hand-written. It drops each index
before its column, which SQLite requires. Desktop applies local-db migrations
on start (apps/desktop/src/main/lib/local-db/index.ts), so this reaches
existing installs the next time they launch.

organizationMembers in the same file has no references at all, and the users
table itself is only imported by the schema. Both are Electric-sync leftovers
rather than Clerk ones, so they are left for a separate decision.

Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
The tile's header timestamp is when we ran the query, which reads as how
current the number is. It isn't: subscription_item_change_events_v2_beta
is documented at 3h freshness and was measured 5h behind tonight, so a
correct figure looked like a stale one and kept prompting "why doesn't
this match Stripe".

Emit MAX(event_timestamp) alongside the series and render it under the
headline as "Stripe data through 2:07 PM", leaving the header's refresh
time alone so both facts are visible and neither is overloaded.

Deliberately not closing the last $40 against the Dashboard. That gap is
subscriptions booked after Sigma's cutoff, and correcting for it means
pricing them ourselves: workable for tonight's two plain USD signups,
but it silently mis-sums the first non-USD subscription (the FX table is
24h stale), guesses at coupon-duration semantics, and mishandles metered
and tiered prices. Those all fail as a confidently wrong headline rather
than a visibly old one, which is the worse trade for a revenue metric.
The FX-spine fix in ParadevAI#7084 already recovered $285 of the original $325.

data_load_time is not the lever it looks like — in the Query Run API it
returns the current time, not the data cutoff.

Claude-Session: https://claude.ai/code/session_018FrUA5omygopkJUSLf78H3
…vAI#7087)

* refactor(automations): one save state for the automation page

The scope chips, the title and the agent wrote the moment they changed while
the trigger set waited for a Save — two habits on one page, and the trigger
set is the one that cannot autosave, since a half-built row is invalid by
construction. Everything editable is a draft now and one Save commits them in
a single patch.

The draft moves up to AutomationBody: TriggersEditor is controlled, and the
Save/Discard pair plus the problem banner move to the page header, where they
also cannot drift down the page as triggers are added.

Deliberately excluded: the Active switch, which is the kill switch and has to
take effect on the click, and the prompt, which keeps its own save.

* fix(automations): put the title into the draft on every keystroke

On blur it raced the click on Save: the blur commits the rename to the draft
and the click reads the draft, and a rename typed and saved without leaving
the field could be dropped. Trimming moves to blur, where an emptied title
falls back to the saved one.

* refactor(automations): one draft hook for the page, and far fewer comments

useAutomationDraft replaces the settings-draft plus useTriggerDrafts pair:
one dirty flag, one adopt-when-clean, one save. useProviderOptions moves up
to the page, which removes the savedAt/firstSave/biome-ignore workaround the
Slack refetch needed. The prompt joins the draft and commits through the same
Save, calling setPrompt only when it changed so versioning stays honest.

Save and Discard move onto the title line. Comments cut to the ones that
encode a constraint rather than restate the code.

* fix(automations): drop the workspace pin when the project or device changes

A pin belongs to one project on one host, so carrying it across a switch left
a trigger that could not run and a picker showing two near-identical 'main'
rows — the stale pin in amber beside the real workspace. Switching now falls
back to 'New workspace', which is always valid; re-picking the same value
leaves a good pin alone.

* fix(automations): derive the page's scope from the draft, not the saved row

The refactor moved the writes onto a draft and left the reads on the server
value, so the page ran half-edited: picking a workspace wrote the saved
project back and snapped the project chip, a switched device still listed the
old host's workspaces, and the agent warning judged the saved agent.

* fix(automations): save the prompt inside update's transaction

The page saved through two calls — update, then setPrompt — so an update that
landed followed by a failed setPrompt left a half-saved automation. update now
accepts a prompt and records its version in the same transaction, and only
when it changed, so a scope tweak doesn't mint an identical version. setPrompt
stays for the CLI, MCP and SDK callers.

Claude-Session: https://claude.ai/code/session_01JZADY3P3hZ7PitayKpGKLg

* fix(automations): don't save twice when the prompt editor loses focus

MarkdownEditor fires onSave on blur, so clicking Save blurred the editor and
saved once from the blur and once from the button — two transactions per
click. The prompt is already in the draft on every keystroke, so the blur has
nothing left to commit.

Claude-Session: https://claude.ai/code/session_01JZADY3P3hZ7PitayKpGKLg

* chore(i18n): translate new strings across enabled locales

* chore(automations): cut comments that restate the code

Claude-Session: https://claude.ai/code/session_01JZADY3P3hZ7PitayKpGKLg

* chore(i18n): recompile catalogs after the rebase

Claude-Session: https://claude.ai/code/session_01JZADY3P3hZ7PitayKpGKLg

---------

Co-authored-by: superset-i18n[bot] <i18n-bot@superset.sh>
…orkspaces (ParadevAI#7053)

* fix(sandbox): ship agent templates, make start.sh re-runnable, and report crashes

Cloud sandboxes had three independent defects, all of which would still be
broken the day after an image rebuild.

The image never copied packages/agent-setup/templates, so every boot logged
"agent hooks will NOT work on this host" and eight setup actions failed. Agents
ran but never reported starting, finishing, or asking a question — for as long
as sandboxes have existed. host-service resolves them as `agent-templates`
beside its own bundle, so they now land at /app/agent-templates, the same
placement the CLI tarball uses.

start.sh was not safe to run twice. Both of its branches destroy work in
progress — the clone path wipes the directory, the fetch path moves the branch
onto the remote's head and orphans commits an agent made and hadn't pushed.
That was survivable only while nothing ever ran the script twice, which stops
being true the moment anything restarts it or updates in place. The repository
bootstrap is now fenced behind a marker written only on success, so an
interrupted first run retries and every later run falls through to serving.

Finally, sandbox crashes were invisible, and not because provisioning forgot a
variable: the host-service Sentry DSN is compiled into the desktop bundle at
desktop build time and handed over when the desktop spawns a host. A sandbox is
started by the API and never sees a desktop build, so it could never receive
one. Sandboxes now report to their own project via SENTRY_DSN_SANDBOX on the
API — separate because that project means the host service on people's
desktops, and because `release` means opposite things on the two surfaces: a
version users happen to have installed versus one we deploy and converge.
Events carry the cloud workspace id, image tag and provider; the workspace id
is what joins a provisioning failure recorded against the API to a runtime
failure recorded against the sandbox.

Notably absent: restartOnFailure. Nothing supervises host-service, so a restart
is the obvious reflex — but it has never been observed to exit. The container
is healthy throughout the investigation above; what looked like a crash was the
desktop calling procedures a 12-day-old image had never heard of. Restarting an
unexplained exit would convert a bug worth investigating into a wobble nobody
sees, and it does nothing for the failure actually hit since: a missing
environment variable throws identically five times and dies anyway. Worth
revisiting once startup errors reach Sentry, which they do not yet.

Verified against a real sandbox provisioned from the live image and torn down
afterwards. Full host-service suite (1439 pass) and trpc suite (122 pass) green.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(trpc,db,desktop): environments as the starting point for cloud workspaces

A cloud workspace used to start from BLAXEL_SANDBOX_IMAGE — one floating image
name shared by every sandbox, so a rebuild silently changed what new sandboxes
got and nothing recorded which build any of them ran. That value becomes a row.

An environment is a named starting point owned by an organization, carrying the
provider's reference as a discriminated pair: source_kind 'image' creates from a
container image, 'snapshot' forks a sandbox someone already configured. Provider
specifics stay in that one row rather than smeared across every workspace, so a
change of sandbox provider touches one table.

Forking is what makes agent-configured environments possible at all: there is no
commit-from-sandbox path to an image, archiving is hibernation, and images build
only from Dockerfiles. A fork inherits the source's environment variables and the
fork request cannot override them, so identity is written in a second call — and
because that update only reaches processes started afterwards, the source must be
snapshotted with host-service stopped and start.sh exec'd only after it lands.

Environments carry their own variables, which resurrects the secrets surface
deleted in ParadevAI#6431 for being unreachable — it was cut for having no reason to
exist, and now it has one. The crypto (AES-256-GCM) ports verbatim. The reserved
key list did not: it guarded a previous provider's vocabulary and none of ours,
so a user-supplied ORGANIZATION_ID would have overridden a sandbox's identity
from a settings form. It now blocks by prefix and is re-checked at resolve time,
so a row written before a name became reserved can never shadow identity either.

cloud_workspaces gains environment_id (not null — the migration clears the two
internal sandboxes rather than backfilling a value we would be guessing at,
their provider sandboxes deleted first so nothing is orphaned), host_version for
what a sandbox was last observed running, and deleted_at, which replaces
overwriting status with "deleted" — that destroyed the record of why a workspace
ended and carried no timestamp.

Requires SECRETS_ENCRYPTION_KEY in the API environment, and at least one
environment row before a cloud workspace can be created.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(i18n): translate new strings across enabled locales

* refactor(trpc): fork sandboxes through the SDK rather than raw HTTP

The pinned @blaxel/core 0.3.11 already exposes `SandboxInstance.fork` and
`updateSandbox` — the hand-rolled fetch calls were written on the assumption it
did not, and no version bump is needed.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(desktop): make environments reachable from settings

The settings sidebar filters entries through getAllowedSectionsForVariant,
which builds its allowed set from SETTINGS_ITEMS — so a nav item alone would
still have been filtered out, leaving the page reachable only by URL. That is
exactly why the surface this revives was deleted in ParadevAI#6431: "URL-only
reachable, zero API calls in 90 days".

Registers the section in both places, plus search keywords, and enforces
Lingui on the new directory.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(i18n): translate new strings across enabled locales

* refactor(trpc): move environment secrets into their own sub-router

environment.secrets.{list,set,remove} rather than environment.{listSecrets,
setSecret,removeSecret}. Matches how integration composes its providers and
how the deleted v1 project router nested secrets, and makes the directory
honest — secrets/ held utils and validation but no procedures.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* refactor(trpc): read SECRETS_ENCRYPTION_KEY through the validated env

process.env bypassed the schema the key was just declared in, so a deployment
missing it failed at the first setSecret call rather than being visible in the
one place env vars are described.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(desktop,trpc): creating an environment only asks for a name

The form asked for a source kind and an image reference, which nobody can
usefully supply — a tag like `superset-hostsvc:hoockx6bbvtx` is meaningless to
whoever is filling in the form, and forking is not something you type either.
The base image is infrastructure we maintain, so the server fills it; forking
sets these itself once an environment has a sandbox worth copying.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* style(desktop): match the settings list pattern on the environments pages

The pages set no page padding at all and rendered bordered cards, neither of
which the rest of settings does. Adopts the shape api-keys and the other
settings pages already use: a `p-6 max-w-4xl` container, an
`items-start justify-between` header holding the title, description and the
primary action, `divide-y` rows rather than cards, an icon plus a truncating
name over muted mono metadata, a hover-revealed destructive action, and the
centered empty state.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(desktop): center the environments settings pages

fullWidth was copied from Hosts, which needs it because it renders its own
sidebar. It opts a section out of the layout's `mx-auto max-w-4xl` column, so
environments rendered flush against the left edge.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(i18n): translate new strings across enabled locales

* fix(desktop): show environments load failures instead of "no environments yet"

A failed list query fell through to the empty state, so a table that does not
exist reads as an organization with nothing in it — which is how a missing
migration looked like a working page with no data.

Also shortens the header description, which wrapped across the column and
crowded the primary action.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(i18n): translate new strings across enabled locales

* feat: save a cloud workspace as a reusable environment

Right-click a cloud workspace in the sidebar to turn the sandbox someone
configured into a starting point for new ones.

Promote forks the workspace's sandbox into one the environment owns rather
than referencing the workspace itself. The workspace keeps running and can be
deleted later, and the copy stays frozen because nothing runs in it — pointing
at a live sandbox would instead re-copy it, mid-work, on every provision.

The copy is then stripped of everything belonging to the workspace it came
from. Each of these is a silent wrong answer if skipped, because a fork
inherits processes, memory and filesystem alike:

- host-service is stopped through the process API rather than killed, since
  the fork inherits restartOnFailure and a killed one returns still holding
  the source workspace's identity
- host.db is removed, or every fork serves the source's seeded rows
- the bootstrap marker is removed, or forks skip their own branch checkout
- the source's GitHub installation token is cleared

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(i18n): translate new strings across enabled locales

* feat(desktop): port the original secrets UI onto environments

Rebuilding the add form inline lost what ParadevAI#6431 already had: a side sheet with
repeatable key/value rows and .env upload, a row with reveal and copy, and an
edit dialog. Those come back close to verbatim — only the data layer and the
scope changed, from a project plus organization pair to one environment.

The container did not come back. It was v1 plumbing: Neon project linking,
electronTrpc.projects, and the v1 project-settings header, none of which an
environment has.

secrets.getDecrypted comes back with them, since the row and the edit dialog
need values. It keeps the original's rule that sensitive entries return empty
rather than decrypted — the list edits the set, it does not recover a secret
someone already stored.

settings/environments comes back out of ENFORCED_DIRS while these strings are
raw; converting copy that is still being reworded would be wasted, and the
i18n pass follows once the screen settles.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(desktop): say which variable was rejected and why

"Failed to save environment variables" named neither the row nor the reason,
and the save loop aborted on the first rejection — so earlier rows were
already written while the toast reported the whole thing as failed.

Rows are now checked before anything is written, the offending input is
marked, and the reason sits under it. A rejection describes what was typed, so
editing the row clears it.

The reasons explain themselves rather than saying "reserved": a SUPERSET_
prefix carries the workspace's identity, HOST_SERVICE_ configures the
sandbox's own server, and a bare name like PORT is set by the sandbox itself.
Knowing a name is unavailable is not much help without knowing what owns it.

The rules move to @superset/shared/environment-secrets so the form and the
router check the same thing, rather than the client guessing at what the
server will accept.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(desktop): stop the secrets list resizing as you use it

Three places where the row changed size for no reason the reader caused:

Revealing a value swapped a 13-bullet placeholder for the value itself, each
sized to its own content, so the row jumped. Both states now occupy the same
box.

The row-actions menu sat flush against the author avatar — the metadata block
is flex-1 and the menu was a sibling with no gap between them.

The empty state was py-8 against a row's py-4 plus 36px of content, so
filtering a list down to nothing made the container taller than the list it
replaced. It now matches a row exactly.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(desktop): hover target hugs the value, and lighten the variable name

The reserved width was on the value button itself, so the copy affordance
highlighted the whole 200px reserved for it — a hover target covering empty
space reads as a bigger control than it is. The width moves to a wrapper: the
row still cannot resize on reveal, and the button stays intrinsic.

The name was mono at semibold, which is heavy for a key sitting next to
regular-weight metadata. Medium matches the name weight the other settings
lists use.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(desktop): show nothing while the variables list loads

A one-row-tall placeholder still resized the moment real rows arrived, since
nothing knows the count in advance. Rendering nothing is honest about that
rather than approximating it badly.

That only works if loading means first load. The parent was remounting the
list with a key after every save, which reset it and would have blanked the
list on each write; it now passes a refresh token, so a refetch leaves the
existing rows on screen.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(desktop): stop the environment header shifting when the name loads

The heading rendered empty until the query resolved, and an empty heading has
no line box at all — so the header had zero height and grew once the name
arrived, pushing the list down.

It now always holds exactly one line: the name, or a non-breaking space while
it loads. A min-height was the obvious fix and measurably did not work, so
this uses the shape that cannot shift rather than one that looks like it
should.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(desktop): center the environments headers against their text block

The action button was items-start, so it sat level with the title while the
description hung below it — reading as two rows rather than one. Both headers
now center against the whole block, and the description gets a max width so it
stops running up against the button.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(trpc): hand a fork its identity in a file, not a sandbox update

Forking was calling PUT /sandboxes/{name} to give the fork its own env vars.
That endpoint re-materialises the sandbox from its image: verified on a plain
sandbox on sandbox-api 2026-08-27, where files under /data, /root, /opt,
/workspace and /tmp all vanished and a git repo baked into the image came back
clean. The root filesystem is a `volatile` overlay, so nothing written at
runtime survives. Forked environments were losing exactly the configured state
they exist to carry.

Provisioning now writes /data/identity.env after forking and start.sh sources
it, which touches no spec. Both sites are marked TODO(2026-09-01) and go away
once a fork can be created with its own environment variables — Blaxel are
integrating that into fork directly.

Verified end to end against real sandboxes: a workspace forked from a promoted
golden keeps the agent's files and the golden's baked env, and resolves its own
workspace id rather than the source's.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(trpc): provision sandboxes with 8GB rather than 4GB

The root filesystem is a RAM-backed overlay, so memory is not just process
headroom — everything written at runtime comes out of the same number. A warm
checkout, node_modules, an agent and a dev server together do not fit
comfortably in 4GB.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(skills): allow applying a migration to a dev branch

The rule read 'never apply one yourself', which is right for anything
shared and wrong for the throwaway Neon branch a worktree owns — where
not applying means the schema change goes untested until deploy.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(trpc,db): ship environments owned by no customer

An environment every organization can start from has to belong to
someone. A sentinel organization owns them, rather than a copy per
customer: rebuilding an image then re-points one row instead of needing
a backfill across every organization that existed when it changed.

Sharing an environment surfaced two faults that were invisible while
every environment had exactly one owner:

- resolveEnvironment and all four secrets procedures selected on
  environmentId alone. Correct when the environment implies its owner;
  a cross-tenant credential leak the moment one does not. Every query
  is now scoped by organization, using ctx.activeOrganizationId, which
  already existed.
- unique(environment_id, key) meant two organizations could not both
  set DATABASE_URL on a shared environment — the second silently
  overwrote the first through onConflictDoUpdate. Migration 0104
  widens it to include organization_id.

Shared rows are readable by everyone and writable by no one: list
unions them, loadEnvironment permits them, and every mutation refuses
them so one tenant cannot rename or archive what everyone else uses.

Also drops MAX_SECRETS_PER_ENVIRONMENT. It was an invented number
guarding nothing the size limits do not already guard, and the provider
accepts 250 variables — measured, not assumed. MAX_TOTAL_SIZE was
declared but never enforced; it now is, in SQL over the stored values.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(sandbox): give a fork its own identity, and stop shipping our repo

Blaxel fixed the update path that made the workaround necessary: an
env-only change no longer resets a sandbox to its image. Verified by
isolating it — a no-op PUT and an image-only change both leave the
writable layer intact, and only an env change destroyed it. forkSandbox
now sets the workspace's identity directly and /data/identity.env is
gone, along with the block in start.sh that sourced it.

The merge keeps what the fork inherited. Values the API redacts to
'****' on read are preserved when written back unchanged, which is what
lets the proxy credential placeholders survive untouched — confirmed by
forking after a masked write and reading the values from inside.

The image also stops carrying the monorepo. It was 230 MiB of our code
in the image every customer gets, and it sat below a bundle that
changes every build, so it re-cloned each time. Removing it takes the
build from ~7 minutes to 2m53s and the image from 2.85 GB to 2.54 GB,
and costs nothing at boot: a blobless clone reaches health in 10s.
Our own checkout is warmed in the internal environment instead.

What the image gains is what it was missing: procps (there was no ps or
pgrep), iproute2 (no ss, so listener checks silently reported nothing),
tzdata, locales, git-lfs, and the network tools that make a failure
diagnosable from inside the box. Plus a virtual display and VNC server,
since a GUI is a capability rather than taste. Agent CLIs and bun are
pinned — unpinned installs in a cached layer are a version nobody chose.

Electron's ~400 MB of libraries stay in the internal fork, with the
shell configuration, because nobody else develops an Electron app.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(host-service): serve a sandbox's display over an authenticated socket

RFB is raw TCP and a browser cannot open a TCP socket, so the bytes have
to arrive over a WebSocket — which is what noVNC expects anyway. This
pipes one to the other and understands nothing about the protocol, so
versions and encodings are negotiated between the client and x11vnc
without this sitting in the middle. Same shape as the browser CDP route,
with a TCP upstream instead of a WebSocket one.

Behind the same wsAuth as /terminal/* and /browser/*, and the port is
fixed rather than taken from the request: a caller-supplied port would
turn an authenticated socket into a way to reach anything on loopback.
x11vnc binds -localhost, so this proxy is the only way in.

Verified against a real display: an authenticated socket returns
'RFB 003.008' from x11vnc.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(sandbox): report crashes to the deploy they came from

SENTRY_DSN_SANDBOX now reaches the API in both deploys, against a
dedicated Sentry project — sandboxes fail differently enough from the
desktop's host service that sharing a project would mean sharing alerts
and quotas for two unrelated fleets.

The environment tag was hardcoded to 'sandbox', which the project
already means, so every event carried a constant. It now carries the
deploy — preview and production sandboxes report to one project and
this is the only thing telling them apart. Same vocabulary the api app
and apps/web already use.

Sandbox tags lose their guards. Undefined values are dropped when the
event is serialised, so a desktop host simply carries none of them, and
run_mode reports the mode it is actually in rather than a hardcoded one.

Note this still cannot see the failure it most needs to: env validation
throws while serve.ts's imports are evaluated, above the catch that
calls captureFatalStartupError, so a missing variable in the deploy
reports nothing. That fix is separate.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* refactor(desktop,db): one migration, dotenv's parser, a pending toast

Three review notes, none of which change behaviour we shipped.

The environments migration was a table creation followed immediately by
an ALTER widening a constraint that had never been deployed. Neither is
on main, so they collapse into one that simply creates the table with
the constraint it should always have had.

The .env parser was hand-rolled and disagreed with itself: validation
matched lines against one pattern and parsing split on the first '=', so
a file could pass the check and then parse into something else. It now
derives validity from parsing, and the grammar is dotenv's rather than
an approximation — dotenv cannot be imported in a renderer, since its
module root pulls in fs, path, os and crypto, but the parser is one
expression and is reproduced with attribution. That fixes backtick
quotes, inline comments, escaped newlines, and CRLF files leaving a
carriage return inside multi-line values. Checked against dotenv itself
across twelve cases; no disagreement.

Promoting a workspace forks a sandbox and waits on a cleanup exec, so it
runs for seconds with nothing on screen. toast.promise covers the gap,
which is what the rest of the app already uses for slow mutations.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(desktop): note where promote-to-environment belongs

Reachable only from a workspace's context menu today, and offered on
workspaces that cannot be promoted — the action requires status 'ready'
and the menu gates only on the host being a cloud one.

TODO(SUPER-2116) marks the site; the ticket carries the design.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(db): stop defaulting a secret to readable

The column defaulted sensitive to false while the API's input schema
defaults it to true, so the two disagreed on the answer — and the
column's was the permissive one, since false is what makes a value come
back decrypted in the editor.

It was never reached: the router is the only insert and always passes
the flag. That is the argument for removing it rather than flipping it.
A dead default that agrees is still a default nobody consults, and NOT
NULL with none forces the case that would actually use it — a seed, a
backfill, a script — to say what it means.

Folded into 0103 rather than added on top; it has not shipped.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(db): clear cloud workspaces before requiring an environment

The migration added environment_id as NOT NULL with no default, which
fails outright on any database that already has cloud workspaces —
production included. It only passed here because the rows had been
deleted by hand first, which is not a thing a deploy can do.

There is no honest default to backfill: an environment decides the image
a sandbox boots from, so picking one for a row that predates environments
would be inventing an answer. The rows go instead, authorised
deliberately — a handful of internal workspaces behind the @superset.sh
gate, all owned by one person.

Their sandboxes outlive this and have to be torn down separately.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* refactor(sandbox): one name for the image, and a seed that actually runs

seedSharedEnvironments had no caller. Not in app code, not in any
workflow — so in production the shipped environment was never created,
every organization would have opened an empty list, and the sentinel
org that list unions against did not exist. It now runs after migrations
in both deploys, which is the only moment it can: it writes rows in the
tables those migrations create, and re-points them at the current image.

It also moves out of packages/db. The seeds there are a library called
from app code — seedDefaultStatuses runs on organization creation, and
from linear and task — while this one is a CLI nobody imports. It lands
beside image.ts, which names the very image it points rows at, and
scripts already reach into packages for exactly this reason.

Dropping its read-back is what let it move: the select only re-read what
the upsert had just written, and was the sole reason the file needed
drizzle-orm, which the root package does not carry.

The image name was configuration three times over — image.ts named it,
a deploy secret named it again, a local .env named it a third time, and
nothing checked the three agreed. A build producing one name while the
secret said another would have pointed provisioning at an image that
does not exist. It is now a constant in @superset/shared that the build
derives from and BLAXEL_SANDBOX_IMAGE overrides, so the fallback is
right rather than absent.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(trpc): let a workspace start from a shipped environment

Creating a cloud workspace matched the environment on the caller's own
organization, so the environments the platform ships — owned by a
sentinel organization nobody is a member of — came back as 'not found in
this organization'. list, loadEnvironment, the secrets procedures and
resolveEnvironment were all taught about shared rows; this consumer was
missed, which left the shared environment visible in the list and
unusable by the only thing that consumes one.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(desktop): wire environments into settings search

The section registered ENVIRONMENTS_LIST and ENVIRONMENTS_SECRETS as
searchable, but nothing referenced them: the page took no visibleItems
and anchored neither id, so searching for either navigated here and
highlighted nothing. The route now resolves the query the way the
appearance route does and the section honours it.

That is also what the thin route shell was for — it looked redundant
because the work it exists to do had not been written yet.

Two dependency warnings while here, with different causes: secret.id was
genuinely unread by the delete callback and goes; refreshToken is a
trigger the parent bumps to force a refetch, so the effect must re-run
on a value its body never reads, and that is now stated rather than
silently unusual.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore: drop explanatory comments

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* refactor(trpc): use the filesystem API to strip a fork's identity

Six typed removals instead of a shell string joined with &&. pkill stays
an exec: pty-daemon is spawned by host-service, so the process API
cannot see it.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(trpc): bind an encrypted secret to the row that owns it

GCM additional authenticated data over (environmentId, organizationId,
key), so a ciphertext moved to another row fails to authenticate instead
of decrypting. That is the same invariant the queries enforce, made
structural: a bad migration or a query that forgets its organization
filter now yields an error rather than another tenant's value.

A version byte goes in front so the format can change without reading
and rewriting every row. Both are free today and cost a backfill once
there are secrets in production, which there are not yet.

Verified: same row decrypts, a different organization and a different
key are both rejected.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(shared): raise the environment secret budget to 128KB

64KB was a number picked before the platform's own ceiling was known.
Blaxel accepts an env payload up to somewhere between 192KB and 256KB —
measured — so the guard has room to sit further from anything legitimate
while still firing before the platform does, which matters because its
failure is 'Failed to create sandbox' with no detail.

The sum is over ciphertext, so the usable plaintext budget is nearer
96KB. A real .env is about 13KB.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(trpc): drop explanatory comments from env

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore: remove explanatory comments

Files this branch created keep nothing but a header where the code is
genuinely unattributable — the dotenv grammar, the pty-daemon exec. Files
it modified keep what main already had, restored verbatim, except one
comment in start.sh that a change made false and which is now corrected
in place rather than rewritten.

What survives is load-bearing: TODO(SUPER-2116), a biome-ignore whose
absence fails lint, and the dotenv attribution.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(desktop): remove the last explanatory comments

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore: rebase onto main

Migration renumbered to 0104: main landed 0103_stage_page_assets while
this branch was out. Regenerated rather than renamed, and the authorised
cloud_workspaces wipe re-added, since generation drops it.

root.ts keeps environmentRouter and drops fileRouter, which main deleted
in ParadevAI#7046. Catalogs recompiled from the .po sources, and the one string
this branch adds is now translated across all sixteen locales — strict
compile fails the build otherwise.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(desktop): a Desktop pane for cloud workspaces

Pairs the renderer with the /desktop/vnc route, which until now had no
way in. noVNC's RFB client against the authenticated socket: the token
goes in the query string because a browser upgrade cannot carry headers,
which is the same reason getHostServiceWsUrlParams exists for terminals.

Offered wherever the workspace resolves to a sandbox, rather than gated
on a capability probe (SUPER-2111). The route already closes with
'No desktop session on this host' when nothing is listening, so a
sandbox without a display shows that rather than hanging.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat(desktop): offer the Desktop pane from the + menu and a hotkey

Three ways in now, all limited to workspaces that resolve to a sandbox:
the pane context menu, the tab bar's + menu, and SPLIT_WITH_DESKTOP.

The hotkey is meta+shift+y rather than the +d that would have matched
'desktop' — SPLIT_DOWN already holds that chord on every platform.
react-hotkeys-hook's "enabled" keeps the binding inert on a local
workspace instead of splitting into a pane that cannot connect.

Eight new strings, translated across all sixteen locales.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(ci): undo a bad rebase resolution in the deploy workflows

Resolving the rebase with --theirs kept this branch's stale copy of both
deploy workflows over main's newer one, silently reverting ParadevAI#7074 and part
of ParadevAI#7078: secrets.* went back to vars.*, MEDIA_URL and BLOB_READ_WRITE_TOKEN
came back from the dead, and R2_PUBLIC_BUCKET, R2_ENDPOINT and STATIC_URL
were dropped.

Reset both files to main and re-apply the only change this branch actually
wants, SENTRY_DSN_SANDBOX. The shared-environment seed step goes with it;
seeding three rows into a database that outlives the deploy is a one-time
manual step, not a pipeline concern.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix: green the build and close the review findings

Two CI failures, both real:

- The desktop build could not resolve @novnc/novnc/core/rfb.js. That package's
  exports field is the bare string "./core/rfb.js", so it publishes only the
  package specifier and no subpaths at all. Vite's dev server resolved it
  anyway, which is why this only surfaced in the Rollup build.
- Mobile could not call cloudWorkspace.create, which had grown a required
  environmentId that only the desktop picker supplies. Make it optional and
  resolve the shared default server-side, the way branch already resolves the
  repo's default: a client with no environment picker must not guess an id.

Review findings worth fixing before this ships:

- Promotion carried the promoting workspace's env into the golden sandbox.
  Blaxel copies env on fork and it is immutable afterwards, so a shared
  environment would hand every later workspace the promoter's git token.
  Provisioning sets that token only when the clone needs one, so a public-repo
  workspace inherited it rather than overriding it. Strip workspace identity
  from the fork's env, mirroring the identity files already removed.
- Promotion swallowed every stop and unlink failure, so an identity-bearing
  host service could stay running in an environment we then published as
  clean. Only absent paths and processes are ignored now.
- useWorkspaceHostTarget kept the sandbox URL unkeyed, so switching cloud
  workspaces reported the new one ready at the old one's address, pointing
  panes at another workspace's sandbox. Key the grant to its workspace.
- loadEnvironment ignored archivedAt, leaving archived environments readable
  and mutable through get, update, archive and every secrets procedure.

Seeding gets a manual, credentialed workflow rather than a per-deploy step,
so a fresh database can be seeded on demand without the pipeline carrying
one-time data setup.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* feat: pick the environment a cloud workspace is created in

Cloud workspaces took whichever environment sorted first, on both clients:
desktop read environments[0] at submit, and mobile could not send one at all.
Neither surface showed which environment a workspace would get, and neither
let anyone choose.

Desktop gains a fourth pill beside the project, rendered only when the device
is Cloud. Mobile puts the picker in the slot the inert "Cloud" chip occupied —
under cloud scope that chip named the place and did nothing, so the row now
reads as the environment and its own picker screen sits beside the project,
branch and agent ones.

Both remember the pick the way they already remember the host, and both fall
back to the first environment until someone chooses, so the default is the
behaviour that shipped rather than an empty picker.

environmentId stays required on create: a client with no picker is a client
that cannot say what it wants, and the server guessing a default hides that.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(i18n): translate new strings across enabled locales

* fix(desktop): show the environment picker on the new-workspace route too

The create surface exists twice: DashboardNewWorkspaceForm's PromptGroup, and
NewWorkspaceScreen, the route arm of the new-workspace-screen experiment, which
does not reuse PromptGroup — it imports the pills and composes its own row. The
picker only landed in the first, so the surface actually rendered showed Cloud,
project and branch with no way to see or choose the environment.

Both share useSubmitWorkspace, so the pick was already honoured once made; only
the control was missing. Caught by driving the running app rather than reading
the diff.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(mobile): keep the Cloud chip and put the environment beside it

The environment picker took the Cloud chip's slot instead of joining the row,
so choosing an environment cost you the label naming where the workspace runs.
Cloud goes back to naming the place, and the environment is its own chip after
it, which is the arrangement desktop already has.

Falls back to "Environment" rather than "Cloud" now that Cloud is its own chip
again, and the Cloud chip returns to being inert on press.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore(i18n): translate new strings across enabled locales

* feat: cloud workspaces have no project

A cloud sandbox never had a project in any sense a person would recognise —
no worktree, no folder, nothing to switch between. It carried one because the
project was the only thing naming a repository to clone, so picking a project
was really picking a repo, spelled wrong, and it dragged a project picker into
two composers that had nothing to do with projects.

The repository is now named directly. Every cloud workspace clones the same
one, which is true today and is what the environment should carry instead
(SUPER-2119) — so the constant is the honest placeholder rather than a project
id standing in for a repo.

- cloud_workspaces.project_id is gone, with its foreign key and index.
- create and listBranches no longer take a project; repoForProject becomes
  repo; listProjects is deleted, which its own comment already called interim
  pending exactly this.
- resolveCloneTarget and listRemoteBranches take a repo, not a project id, so
  repo-for-project.ts has no callers left.
- SUPERSET_SANDBOX_PROJECT_NAME had one source and no replacement; dropped.
- Desktop hides the project pill under Cloud and stops requiring one.
- Mobile offers a single cloud target rather than one per project, and its
  Home list was already flat under cloud scope, so nothing regroups.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* chore: retrigger ci

The last two pushes produced no workflow runs at all, so nothing has
checked the project removal or applied 0105 to the preview branch.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(sandbox): start the display server the desktop pane connects to

The image installs Xvfb, openbox and x11vnc and host-service proxies
/desktop/vnc onto 127.0.0.1:5900, but nothing ever launched them, so a fresh
sandbox answered the pane with "No desktop session on this host". The earlier
RFB handshake only passed because the test started them by hand.

x11vnc binds loopback only; the pane reaches it through host-service's
authenticated proxy. All three are fire-and-forget: a missing display costs
the pane, never the workspace.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* test(sandbox): a smoke test that provisions a real sandbox

Runs the same provisionSandbox the API uses, then checks what a workspace
actually needs: host-service answering, the repo on the requested branch,
the bootstrap marker, the display server, and an RFB handshake through
host-service's /desktop/vnc proxy. Deletes the sandbox either way.

It deliberately skips the API wrapper — the row, the QStash delivery, and the
GitHub App token mint. The mint needs the production App key, which is why
the clone here is unauthenticated and the repo must be public.

    bun run sandbox:smoke

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(sandbox): run the smoke test from the root so .env loads

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* test(sandbox): --keep and SMOKE_WORKSPACE_ID for poking at a live sandbox

--keep leaves the sandbox up and prints its preview URL. SMOKE_WORKSPACE_ID
seeds the host's workspace row with a given id, which is what lets a real
cloud_workspaces row open it in the app: the workspace layout shows the
provisioning screen until the host reports a workspace with the same id.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix: close the last review findings

- start.sh waited a fixed second before starting openbox and x11vnc; on a
  slow Xvfb both die and the pane has nothing to connect to. Wait for the
  display socket instead.
- useCloudRepoPrefix asked the API for the cloud repo even when cloud
  workspaces are off for the account, which is a FORBIDDEN on every Home load.
  Gate it the way the other cloud hooks are.
- The create args carried a target nothing read once the project went away.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

* fix(i18n): stop regenerating catalogs inside typecheck

turbo's typecheck task depends on ^build, not ^typecheck, so
@superset/i18n#typecheck ran alongside every dependent's tsc. Its
pretypecheck rewrote all seventeen locales/*/messages.ts while admin, desktop,
mobile and the rest were type-importing them, and whichever read mid-write
got a truncated file — the intermittent "unterminated string literal" in
es/messages.ts, and today a ko catalog whose export no longer typed.

The regeneration was already redundant: CI's Lint job runs the i18n check,
which extracts, compiles --strict, and fails on any diff against the committed
catalogs. Typecheck now reads those committed files and nothing writes them
concurrently.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY

---------

Co-authored-by: superset-i18n[bot] <i18n-bot@superset.sh>
…ground (ParadevAI#7089)

destructive-foreground is the label colour for text sitting on a
bg-destructive surface, so themes set it to their page background.
Fifteen sites used it as a freestanding red (diff deletion counters,
check-failure icons, closed-PR and changes-requested badges, attachment
warnings, file-pane error banners). It only looked right on the default
dark theme, whose value happens to be pale pink; on Kintsugi the -484
next to +24636 rendered black on black, and on the light theme it was
near-white on white. Switch them to text-destructive so every theme
gets its own red. The three real button and hover cases keep the pair.

Claude-Session: https://claude.ai/code/session_01WNYfXfvbc1km7yfoRKk9b5
…evAI#7088)

The shared environment was seeded into production on 2026-09-02 (run
33592305966). A one-time data step does not belong in the workflow list; the
script stays for dev and preview branches: bun run db:seed-shared-environments.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY
docs/environment-variables.md says always a secret, never a repo variable, so
that one mechanism means one place to look when a value goes missing. Five
variables predated the rule: R2_PRIVATE_BUCKET, GH_APP_SLUG, SUPERSET_API_URL,
SUPERSET_WEB_URL and NEON_PROJECT_ID, across nine references in five workflows.

Both build-cli.yml callers already pass secrets: inherit, and it already reads
secrets.RELAY_URL beside the two it did not; cleanup-preview.yml already needs
secrets.NEON_API_KEY, so a variable bought its project id nothing. The secrets
are set with the values the variables held, so no deploy changes behaviour.

Note the workflow_call inheritance trap in the doc, since that is the one way
this change could have failed silently.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY
The image a sandbox boots from is the environment row's sourceRef, and has
been since environments landed; provisioning never read this variable. It
survived in two places — environment.create defaulting a new row, and the
shared-environment seed — and both now use SANDBOX_IMAGE_NAME directly.

Removes the schema entry, both deploy workflows' pass-through, and the three
scripts that fell back to it. The GitHub secret can be deleted after this
merges.

Claude-Session: https://claude.ai/code/session_01HmfE4Qm77AwKUxJeFVMetY
Adds three MCP tools over the host tRPC procedures that already back
Settings → Agents and `superset agents presets` (ParadevAI#7269):

- agents_presets_add    → settings.agentConfigs.add
- agents_presets_edit   → settings.agentConfigs.update
- agents_presets_remove → settings.agentConfigs.remove

`agents_create` stays the launch verb; preset CRUD is namespaced under
`agents_presets_*` so it survives the roadmap's `agents_run` rename.

The launch shape is accepted two ways — structured `command` + `args`, or
a single `launchCommand` line the CLI's tokenizer splits — and passing both
is rejected rather than resolved by precedence. `command` and `args` always
replace each other as a pair, so a stored launch line is never half of one
caller's intent and half of an earlier one's.

`tokenizeAgentCommand` is exported from @superset/shared rather than
re-implemented; it was already the private splitter behind the seed presets.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

We couldn't safely recover the incremental review. No full review was started, and the last reviewed checkpoint was preserved. Retry later, or explicitly request a full review by commenting @coderabbitai full review.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: f8ca4338-af5a-43f8-93b3-49c22f538eea

📥 Commits

Reviewing files that changed from the base of the PR and between 451ad02 and 19387b9.

📒 Files selected for processing (13)
  • packages/agent-setup/src/disabled-agent-hooks.test.ts
  • packages/mcp/package.json
  • packages/mcp/src/tools/agents/presets/add.test.ts
  • packages/mcp/src/tools/agents/presets/add.ts
  • packages/mcp/src/tools/agents/presets/edit.test.ts
  • packages/mcp/src/tools/agents/presets/edit.ts
  • packages/mcp/src/tools/agents/presets/launch-input.ts
  • packages/mcp/src/tools/agents/presets/remove.test.ts
  • packages/mcp/src/tools/agents/presets/remove.ts
  • packages/mcp/src/tools/agents/presets/test-harness.ts
  • packages/mcp/src/tools/agents/presets/types.ts
  • packages/mcp/src/tools/register.ts
  • packages/shared/src/host-agent-presets.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Walkthrough

The MCP package adds agent preset add, edit, and remove tools. Shared launch parsing supports structured commands and launchCommand. Tests cover validation, host mutations, responses, and test isolation.

Changes: Agent preset management

Layer / File(s) Summary
Launch parsing and preset data contracts
packages/shared/src/host-agent-presets.ts, packages/mcp/src/tools/agents/presets/launch-input.ts, packages/mcp/src/tools/agents/presets/types.ts
Exports tokenizeAgentCommand, defines HostAgentConfig, and normalizes structured or string launch inputs.
Add and edit preset tools
packages/mcp/src/tools/agents/presets/add.ts, packages/mcp/src/tools/agents/presets/edit.ts, packages/mcp/src/tools/agents/presets/test-harness.ts, packages/mcp/src/tools/agents/presets/add.test.ts, packages/mcp/src/tools/agents/presets/edit.test.ts
Adds validated preset creation and partial update tools. Tests cover launch parsing, patches, defaults, errors, host calls, and responses.
Remove tool and MCP registration
packages/mcp/src/tools/agents/presets/remove.ts, packages/mcp/src/tools/agents/presets/remove.test.ts, packages/mcp/src/tools/register.ts, packages/mcp/package.json
Adds destructive preset removal, registers all preset tools, and adds the package test script.
Test isolation and temporary homes
packages/agent-setup/src/disabled-agent-hooks.test.ts
Creates a fresh temporary home and clears shared state for each test.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MCPTool
  participant LaunchParser
  participant HostService
  participant AgentConfigSettings
  Client->>MCPTool: Submit agent preset request
  MCPTool->>LaunchParser: Resolve command and args
  LaunchParser-->>MCPTool: Return normalized launch
  MCPTool->>HostService: Call agent configuration mutation
  HostService->>AgentConfigSettings: Add, update, or remove preset
  AgentConfigSettings-->>HostService: Return mutation result
  HostService-->>MCPTool: Return host response
  MCPTool-->>Client: Return preset result
Loading
Merge Risk: Minimal · previous review 19387

No concrete merge-blocking risk is established for the current changes.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 12 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses conventional commit format and clearly identifies the primary change: adding MCP tools to add, edit, and remove agent presets.
Description check ✅ Passed The description includes the required What & why, How I tested it, and Checklist sections. It clearly explains scope, implementation, testing, and out-of-scope items. The repository checklist is adapt…
Full details: Docstring Coverage

Explanation

Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 12 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

agent-wrappers.test.ts replaces "./paths" with mock.module() for the whole
process and never restores it, and bun runs a package's test files in one
process in no guaranteed order. When that file loads first,
resolveSupersetHomeDir() in disabled-agent-hooks.test.ts ignores
SUPERSET_HOME_DIR and resolves into agent-wrappers' root instead. The
round-trip test's write then lands in a file the afterEach here never
deletes, so "treats a missing or corrupt file as nothing disabled" reads
["claude", "codex"] and fails.

Give each test a fresh mkdtemp home, and clear the state file at the path
the tests actually read so a stale write cannot survive whichever module
graph is in effect. Same shape disabled-skills.test.ts already uses.
@owieschon
owieschon marked this pull request as ready for review September 8, 2026 02:33
@owieschon

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Already reviewed the last commit. Use @coderabbitai full review to rerun a review of the entire changeset.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


1 out of 6 committers have signed the CLA.
✅ (talarczykco)[https://github.com/talarczykco]
❌ @ashbrener
❌ @hubertmine
❌ @joaobnobre
❌ @0x962
❌ @ruan Gustavo
Ruan Gustavo seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@talarczykco

Copy link
Copy Markdown
Contributor

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Nov 2, 2026
Kitenite added a commit that referenced this pull request Nov 8, 2026
* chore: seed CLA signature file

* @tanxarx has signed the CLA in #8881

* @ozeitis has signed the CLA in #8888

* @shubhankiit has signed the CLA in #8894

* @chrisfentiman has signed the CLA in #8910

* @RohithReddy20 has signed the CLA in #8907

* @larcane97 has signed the CLA in #8922

* @moizghumann has signed the CLA in #8935

* @andyst-dev has signed the CLA in #7930

* @svenpeeters has signed the CLA in #8943

* @kenil-kanani has signed the CLA in #8940

* @akshatcoder-hash has signed the CLA in #8963

* @NilotpalK has signed the CLA in #8995

* @serhii-kucherenko has signed the CLA in #9000

* @tonydzi has signed the CLA in #9002

* @adaam2 has signed the CLA in #9038

* @tsavo-at-pieces has signed the CLA in #9059

* @ganeshmshetty has signed the CLA in #9130

* @aaronrosenthal has signed the CLA in #9135

* @alexnault has signed the CLA in #9138

* @kunalgoesbyken has signed the CLA in #9235

* @Joaco-gavernet has signed the CLA in #9236

* @theblondealex has signed the CLA in #8240

* @talarczykco has signed the CLA in #7274

* @throw99 has signed the CLA in #7906

* @lacherogwu has signed the CLA in #9310

* @KGKallasmaa has signed the CLA in #9316

* @Niltonsf has signed the CLA in #9331

* @Anmol1578 has signed the CLA in #9351

* @RyoKusnadi has signed the CLA in #9317

* @thedotmack has signed the CLA in #9414

* @abudker has signed the CLA in #9439

* @sanchitmonga22 has signed the CLA in #9443

* @Bruno-rose has signed the CLA in #9446

* @Ashad001 has signed the CLA in #9487

* @vivek-pothina has signed the CLA in #8359

* @colingreig has signed the CLA in #9489

* @anmol0b has signed the CLA in #9492

* @resteqs has signed the CLA in #9506

* @pgilmore has signed the CLA in #7878

* fix(desktop): name the DMG from the display name

The DMG file and its volume title now use DESKTOP_DISPLAY_NAME, so a
Paradev build ships Paradev-<version>-<arch>.dmg. Unset, names stay
Superset. The release keeps the Superset-<arch>.dmg alias beside the new
one, since the README, docs and download button link to it. The .app
bundle and the -mac.zip update files keep the Superset name.

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.