Skip to content

Tags

Tags

v0.32.3-nightly.42

Toggle v0.32.3-nightly.42's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
🤖 fix: put the phone header beside the status ring on the unfolded iP…

…hone Duo in portrait (#8164)

## Summary

On the unfolded iPhone Duo held in portrait, the phone header now sits
in the top strip beside the iOS status ring, where it used to start 82
pt down under an empty bar.

## Background

On that display iOS reports its 82 pt portrait status bar as the top
safe-area inset, but it draws only a clock and a status ring in the top
right corner. Using the full inset left an empty bar across the whole
width. Native apps put their content beside the ring.

## Implementation

- New `--safe-area-top` CSS variable (defaults to
`env(safe-area-inset-top)`) used by the app shell, the phone header, the
sidebar drawer and the left sidebar toggle bar, plus the static
first-run view.
- A media query that matches only the Duo's inner display in portrait
(`device-width: 466px`, `device-height: 678px`, `min-width: 600px`; the
Duo reports the same screen on both displays and only the inner one is
wider than 600) lowers it to `min(inset, 25px)` and reserves
`--ios-status-corner` on the header's right so its buttons stop left of
the ring.
- Dialogs and sheets keep the full inset. `min()` leaves a web clip that
reports no top inset unchanged.

## Validation

- Device Hub simulator, unfolded inner display rotated to portrait,
black-translucent web clip: the header row moves from y 82 pt to y 25
pt, rightmost button ends at x 533 pt, left of the ring at x 549 pt.
- Same simulator: the cover display and the inner display in landscape
render as before.
- `.storybook/test-runner.ts` (`assertDuoInnerPortraitTopStrip`,
Chromium) emulates the 466x678 screen, a 669x951 viewport and an 82 pt
top inset through CDP. It asserts the header top, the shell's top
padding, that the header's controls end left of the ring, that a missing
inset stays 0, and that another screen with the same viewport keeps the
full inset. It fails with `main`'s CSS.

## Screenshots

Device Hub simulator, unfolded inner display in portrait, dark theme
(top of the screen).

**Before (`main`):** the header starts 82 pt down, under a bar that
holds only the clock and ring.


![before](https://raw.githubusercontent.com/coder/xum/c4980169ae961676d569941946a93c4c9919df5e/duo-portrait-top-before.png)

**After:** the header sits at the top, beside the ring; its buttons stop
left of it.


![after](https://raw.githubusercontent.com/coder/xum/c4980169ae961676d569941946a93c4c9919df5e/duo-portrait-top-after.png)

## Risks

Low. The media query matches only the Duo's inner display in portrait;
every other device keeps `env(safe-area-inset-top)`.

Known limitations, not changed here:
- Home Screen icons added with the older (non-translucent) status-bar
style get no top inset, so they see no change until the icon is
re-added.
- With the translucent style, iOS starts the web app at 869 of 951 pt,
leaving an 82 pt empty band at the bottom until the page grows. `main`
has the same band; a follow-up should fix it for this layout.

> Xum opened this PR on Mike's behalf.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`high` • Cost: `$9.01`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high
costs=9.01 -->

v0.32.3-nightly.41

Toggle v0.32.3-nightly.41's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
🤖 feat(tasks): let task_remove force-remove a sub-agent with unsaved …

…work (#8153)

## Why

The goal, in the maintainer's words: "allow xum to force remove
sub-agents and workspaces with a flag. I'm not interested in cleaning up
after you."

Today `task_remove` refuses to remove a sub-agent that holds unsaved
work. It then tells the agent that the user must remove the child by
hand from the command palette. An agent that owns 13 finished children
cannot clean up after itself, and the user must do it.

This PR gives `task_remove` a `force` flag. Workspace archive is a
separate PR (see Scope).

## What changed

- `task_remove` takes `force: boolean | null`
(`src/common/utils/tools/toolDefinitions.ts`). Only `force: true`
forces. `false`, `null` and an omitted flag keep the refusal.
- `TaskService` models the choice as one typed value: `lossyWorkPolicy:
"refuse" | "force"` (`src/node/services/taskService.ts`). A model-driven
removal always sets it. The palette and automatic cleanup still omit it.
- Under `"force"`, the removal skips only the unsaved-work refusal.
Under both policies these refusals stay: a running background bash
process, an external editor or native terminal the user opened, a
pending patch artifact, an active sub-agent, and remaining descendants.
- A forced removal still computes the same description of the lost work,
under the same locks, right before the removal. When the removal
succeeds, the `removed` result carries `deletedWork` (what was deleted,
past tense, plus the checkout path and the branch) and `paths` (the file
list). A checkout that could not be checked is removed anyway, and
`deletedWork` says it could not be checked.
- The branch in `deletedWork` is the workspace name. I verified that
each runtime forks the sub-agent checkout with `newWorkspaceName:
plan.workspaceName` and that removal deletes the branch by that name.
- The refusal text now tells the agent that `force: true` exists and
when to use it: the user asked for cleanup, or the work is provably
saved (pushed, merged, or in an applied patch). It keeps "you must not
delete that work yourself to get around it". It no longer sends the user
to the command palette.
- A forced removal that fails after it deleted the checkout (for example
when the config write fails) returns the report on the error result too,
worded "may have deleted". A retry finds no unsaved work, so the failure
is the only place to report it.
- When the runtime leaves part of the checkout behind (a forced removal
then succeeds with warnings), the report says some of the work may
remain and lists what was left. It never claims a full deletion that did
not happen.
- `WorkspaceService` now returns a warning when a forced removal could
not delete the checkout, even if the runtime named no leftover (single
runtime and multi-project). Before, it logged the failure, removed the
row and returned nothing, so no caller could tell that the checkout
might still exist. The palette and parent-delete paths still succeed as
before and only gain that warning.
- The `task_remove` card shows `deletedWork` and `paths` on removed and
failed rows.
- Generated docs (`docs/hooks/tools.mdx`, the docs skill snapshot)
follow the schema.

## Scope

- This PR covers sub-agents (`task_remove`) only.
`task_workspace_lifecycle` (workspace archive) is the sibling PR, and
its behavior is unchanged here. The one shared piece is a type:
`TaskService` returns the shared `WorkspaceLifecycleResult`, so this PR
adds the optional `deletedWork` field to
`TaskWorkspaceLifecycleBaseResultSchema`. The archive tool does not set
it.
- No partial fix for "commits reachable from a remote ref or
patch-equivalent to origin/main do not count" (decision D5 below).
- No new IPC field and no change to the palette (D2).

## Tradeoffs

- The guard against misuse is a prompt rule, not enforcement. The tool
description and the refusal text say to set `force` only when the user
asked for cleanup or the work is provably saved. The maintainer accepted
this.
- `force` is all or nothing for the unsaved-work check. It does not take
a list of paths to keep.
- A forced removal that fails before it deletes anything (mutation gate,
a new attempt, a stream stop timeout) still says "may have deleted".
Those failures share one error result with the ones that fail after the
deletion, and telling them apart would mean matching error text. The
wording is hedged and nothing is lost, so I left it.
- `deletedWork` is one string, not several fields. The model and the
card both read prose, and one field keeps the result small.
- I renamed the private characterization probe
`refuseLossySubagentRemoval` to `settleLossySubagentRemoval` in
`taskService.removeInactiveDescendant.test.ts`. The step now returns a
refusal or a forced report, so the old name was wrong.

## Blast Radius

- Anyone who can call `task_remove` on their own descendants can now
delete their unsaved work. Scope checks are unchanged, so an agent
cannot force-remove another tree's children or a user's workspace.
- The default call behaves as before, except for the refusal wording.
- Automatic cleanup, the command palette, and the parent-deletion
cascade do not change.

## Decisions (root, 2026-11-12)

- D1. Flag shape: `force: z.boolean().nullish()`. The handler tests
`args.force === true`.
- D2. Agent tools only. The palette and IPC removal already delete
without this check, after the user confirms.
- D3 (task_remove half). Force skips only the unsaved-work refusal:
uncommitted and untracked files, commits without a ready patch artifact,
commits on other branches or the stash, and "cannot verify" failures. It
keeps the checks for someone still working in the checkout.
- D4. A forced removal reports what it deleted: the summary, the
checkout path, the branch, and `paths`. The card shows it.
- D5. No partial fix. It does not meet the request (a flag), and squash
merges are not patch-equivalent per commit, so the flag covers those
cases.
- Threat model, short form:
  - Who can set the flag: any agent that owns the target sub-agent.
- What can be lost: uncommitted and untracked files, commits not
captured by a patch artifact, commits on other local branches or the
stash in self-owned repositories.
- Audit: every forced deletion is reported in the tool result and on the
card.
- Not changed: what removal deletes, scope checks, and the user-mediated
paths.

## Question for @ThomasK33

`force` skips only the unsaved-work refusal. A running background bash
process and an editor you opened still refuse. Do you want `force` to
skip those too?

- Option A (current): keep those refusals. The agent can stop its own
background bash with `task_stop`. An editor you opened has no agent-side
fix, so those children still need you.
- Option B: skip them too. The agent never needs you, but it can delete
files while a process or editor still writes to them.
- My recommendation is Option A, because those refusals protect work
that is still changing, not saved work.

## Screenshots

Dark theme, from the `TaskRemoveForcedNarrow` story, cropped to the
card.

375px:

![task_remove card at
375px](https://raw.githubusercontent.com/coder/xum/e06309d430cd030d910635881d670fa4dfcefede/task-remove-forced-375.png)

Wider card:

![task_remove card,
wide](https://raw.githubusercontent.com/coder/xum/e06309d430cd030d910635881d670fa4dfcefede/task-remove-forced-760.png)

## Verification

- `taskService*.test.ts` plus the cascade and multi-project removal
suites: 1810 pass, 0 fail (58 files). All `workspaceService*.test.ts`:
1248 pass, 0 fail (52 files). Tool, common, browser Tools,
`workspaceService.pendingRemoval` and `subagentRemovalWorkCheck` suites:
2570 pass, 0 fail (136 files).
- New tests: a real git checkout with an uncommitted file and an
uncaptured commit (refuse refuses, force removes and reports the path,
the commit count, the checkout and the branch), an unverifiable
checkout, a clean checkout, a removal that leaves files behind, a
removal that fails, a runtime that fails to delete the checkout (real
worktrees: parent cascade warning and the task_remove report), the same
for a multi-project workspace, removal warnings, both policies keep the
live-writer guards, force keeps the scope, descendant, active-child and
pending-patch refusals, the tool passes `force` only for `true`, and the
card shows `deletedWork`.
- Hand mutants, each turned red by the named test: force skipped, report
dropped, live-writer guard dropped (background bash and editor), force
skips the scope, descendant, active or pending-patch refusal, refusal
dropped, failed-removal report dropped or worded "Deleted", leftover
caveat dropped or always added, tool ignores `force`, tool always
forces, tool drops `deletedWork`, card hides `deletedWork`.
- `make typecheck`, oxlint, ESLint on the 11 changed files, Prettier,
docs sync check, `make crap-check`, `make dry-check`: pass.
- `make mutate-check` for `taskService.ts` runs only the 10 nearest test
files, and they do not include `taskService.lifecycle.test.ts`. Its
survivors are a test-selection artifact. I ran each survivor by hand
against the lifecycle suite, and each is now killed.
- Story play test `TaskRemoveForcedNarrow` passes (the story sets no
Pixel snapshot, because the snapshot budget is at its cap).
- Not run on my host: `hadolint` (not installed, and I changed no
Dockerfile).

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh` • Cost: `$4.57`_

<!-- mux-attribution: model=anthropic:claude-sonnet-5-5 thinking=xhigh
costs=4.57 -->

v0.32.3-nightly.29

Toggle v0.32.3-nightly.29's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
🤖 fix: send GLM 5.3 reasoning_effort on custom OpenAI-compatible prov…

…iders (#8149)

> Xum opened this PR for Mike.

## Summary

GLM 5.3 on a custom OpenAI-compatible provider (for example local oMLX
serving `GLM-5.3-Flash-oQ4e-mtp`) ran every turn at Max thinking,
whatever the picker said. Xum now recognizes self-hosted GLM 5.3 ids and
sends `reasoning_effort` (`low`, `high` or `max`) to those providers,
and only for models it recognizes as GLM 5.3.

## Background

- `buildProviderOptions` returned `{}` for every custom
`openai-compatible` provider, so the SDK never wrote `reasoning_effort`.
- GLM's chat template honors only `reasoning_effort: "low"` and
`"high"`. Anything else (absent, `medium`, `xhigh`, `max`) renders
"Reasoning Effort: Max".
- `isGlm53Model` matched only `glm-5.3[-flash]` with a digit or `latest`
suffix, so `GLM-5.3-Flash-oQ4e-mtp` got the default off/low/medium/high
picker.

## Implementation

- `isGlm53Model` (`src/common/types/thinking.ts`) also accepts the tags
that self-hosted deployments append: a `-` or `:` followed by a token
that carries a digit or names a packaging format (`mtp`, `mlx`, `gguf`,
`awq`, `gptq`). Ids are already lowercased by
`stripModelProviderPrefixes`. Z.ai's named variants (`FlashX`, `Air`,
`Turbo`) are plain words, so they stay unmatched.
- A new `openaiCompatibleGlm53Options` helper replaces the final `return
{}` of `buildProviderOptions`. It returns `{ openaiCompatible: {
reasoningEffort } }` when the raw prefix is a custom `openai-compatible`
provider and the capability model (the id, or its `mappedToModel`) is
GLM 5.3. Every OpenAI-compatible chat model reads the `openaiCompatible`
key, whatever the custom provider's name, and the SDK writes it as
`reasoning_effort`. Unrecognized models still get no field, since strict
servers may reject unknown fields.
- The effort mapping is shared with the Z.ai builder
(`glm53ReasoningEffort`): `max` -> `max`, `high`/`xhigh` -> `high`,
everything else -> `low`.

### Mapping decision

The brief proposed `medium` -> `high` and `xhigh` -> `max`. The GLM
thinking policy (`low`, `high`, `max`) clamps the level before
`buildProviderOptions` runs (`medium` -> `low`, `xhigh` -> `high`), so
those inputs never reach this code on a turn. I reused the Z.ai mapping
so the same level behaves the same on Z.ai and on a self-hosted GLM.

## Validation

- Unit tests run the real `createOpenAICompatible` model against a
capturing `fetch` and assert the `reasoning_effort` in the request body:
every level off..max, a `mappedToModel` alias, and no field for
unrecognized ids (a Qwen id, `glm-5.3-flashx`). Policy tests cover the
new id shapes and the named variants. Three of the new tests fail
without the fix.
- Wire check in a `dev-server-sandbox` from this branch, with its own
`providers.jsonc` (`omlx` -> a logging spy -> oMLX):

  | Model | Picker level | Request body |
  | --- | --- | --- |
  | `omlx:GLM-5.3-Flash-oQ4e-mtp` | low | `reasoning_effort: "low"` |
  | `omlx:GLM-5.3-Flash-oQ4e-mtp` | high | `reasoning_effort: "high"` |
  | `omlx:GLM-5.3-Flash-oQ4e-mtp` | max | `reasoning_effort: "max"` |
  | `omlx:spy-unrecognized-model` | high | no `reasoning_effort` |

On a small puzzle the completion tokens were 5 (low), 5 (high) and 45
(max).

## Risks

Low. The new branch runs only for custom `openai-compatible` providers
whose model resolves to GLM 5.3; every other provider path is unchanged.
The broader id match also applies to the Z.ai builder and the thinking
policy, so a Z.ai id with a deployment tag now gets the GLM levels.

Notes:

- The per-model default floor is `medium` for recognized GLM (as for
`zai:glm-5.3-flash`), so the picker shows high and max until the user
lowers the floor in Settings > Models. This is the existing GLM
behavior.
- Follow-up, not in this PR: the sidebar status generator
(`propose_status`) sends no provider options for any provider, so GLM
status summaries still run at Max.
- Follow-up, not in this PR: a custom provider whose id shadows a
built-in (`zai`, `openai`, `google`, ...) still gets that built-in's
provider options, and the OpenAI-compatible SDK spreads them raw onto
the request body for every model. Shadowed `zai` and `openai` already
carry `reasoning_effort` that way; the others send fields such as
`thinkingConfig` or `searchParameters` instead. That predates this PR
and needs its own change to the format selection.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`xhigh` • Cost: `$7.47`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=xhigh
costs=7.47 -->

v0.32.2

Toggle v0.32.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
🤖 release: v0.32.2 (#8119)

> Xum opened this PR for Mike.

## Summary

Version bump for the v0.32.2 patch release. It ships the new models on
main since v0.32.1: Gemini 4 Carbon (#8108) and Claude Fable 5.2 and
Mythos 5.2 as the `fable` and `mythos` models (#8078), with the Fable
5.2 refusal fallback (#8094) and Fast mode removed from Claude Opus 4.8
(#8099). It also carries every other change on main since v0.32.1 (297
commits).

## Implementation

Bumped with `node ./scripts/set-package-version.js 0.32.2`, so the root
`package.json` and the legacy `packages/mux-compat` forwarding package
stay version-locked, as in #7686.
`src/common/compat/productIdentity.test.ts` passes locally.

After this PR merges, the GitHub Release `v0.32.2` will be published on
the squash commit. That starts the desktop, Docker (`latest`), VS Code
and npm (`latest`) pipelines.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`high` • Cost: `$19.92`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high
costs=19.92 -->

v0.32.1

Toggle v0.32.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
🤖 release: v0.32.1 (#7686)

## Summary

Version bump for the v0.32.1 release. It ships the Electron 44.10.0 bump
(#7684), whose bundled Node 24.22.1 has the Brotli decoder fix for
CVE-2026-111497 ("Node2Shell"), and the guard for `xum run` and `xum
workflow` (#7383). It also carries every change on main since v0.32.0.
The GitHub Release notes will list the highlights.

## Implementation

Bumped with `node ./scripts/set-package-version.js 0.32.1`, so the root
`package.json` and the legacy `packages/mux-compat` forwarding package
stay version-locked. A stable npm publish uses the `package.json`
version as-is. `src/common/compat/productIdentity.test.ts` passes
locally.

After this PR merges, the GitHub Release `v0.32.1` will be published on
the squash commit. That starts the desktop, Docker (`latest`), VS Code
and npm (`latest`) pipelines.

v0.32.0

Toggle v0.32.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
🤖 fix: refuse Brotli responses while Electron's Node lacks the CVE-20…

…26-111497 fix (#7374)

## Summary

The desktop app's Electron 44.5.1 bundles Node 24.21.0. That Node is
affected by CVE-2026-111497 ("Node2Shell"), and its `fetch` decodes
`Content-Encoding: br` responses with the vulnerable Brotli decoder.
Until Electron ships a 44.x with Node 24.22.1 or newer (#7358), the
backend asks servers for gzip or deflate only and refuses a Brotli
response before `fetch` decodes it. The guard turns itself off on Bun
and on any Node with the fix, so it goes quiet once the Electron bump
lands.

## Background

#7359 closed the inbound path: the API server refuses compressed request
bodies. Outbound fetches were still open: a malicious or compromised MCP
server, OAuth endpoint or provider base URL could answer with crafted
Brotli data. Electron has merged Node 24.22.1 into its 44 branch
(electron/electron#55162) but has not released it yet.

## Implementation

- `refuseBrotliResponses` is an undici interceptor. It removes `br` (and
the `*` wildcard) from `Accept-Encoding`, and it aborts a response whose
`Content-Encoding` lists `br` at the header stage, so the body never
reaches the decoder. A block inside the decoder makes `fetch` hang
instead of failing, so the guard sits on the dispatcher.
- Its handler forwards every call itself instead of extending undici's
`DecoratorHandler`, which allows one response start: undici starts one
for each informational response (103 Early Hints) before the final one.
- `ServiceContainer` installs it on the global dispatcher, which covers
every plain `fetch` (MCP SDK transports, OAuth). Desktop, `xum server`
and ACP all build a `ServiceContainer` before any service runs. If
Node's own fetch ran first on Node 22, the global dispatcher comes from
Node's bundled undici 6, which calls the older handler methods, so a
dispatcher from the locked undici takes its place there.
- A `dispatcher` passed to `fetch` bypasses the global one, so the three
pools that `fetch` uses directly are wrapped too: provider calls
(`providerModelFactory`), the bash AI proxy and server updates.
- Model discovery, Bedrock discovery and MCP icons use `undici.request`,
which never decodes `Content-Encoding`, so they need nothing.
- `BROTLI_FIXED_NODE_RELEASES` moves from `src/cli/nodeVersion.ts` to
`src/node/utils/nodeBrotliFix.ts`, so the CLI warning and the guard
share one table. The Docker build's Node check now copies both compiled
files with their `dist/` paths, because `nodeVersion.js` requires the
table by a relative path.

Not covered: `google-auth-library` (Vertex sign-in) fetches through
`node-fetch`, which decodes Brotli itself. It talks only to Google's
token endpoints.

## Validation

- `brotliResponseGuard.test.ts` runs real undici `fetch` against a local
server. Without the interceptor the Brotli body decodes; with it the
request fails. Each test fails when the refusal or the header rewrite is
removed. The 103 Early Hints test failed with `ERR_ASSERTION` on the
`DecoratorHandler` version.
- The compiled guard on Node 22.19.0, Node 24.21.0 and Electron 44.5.1,
each with and without a `fetch` before the install: gzip and 103 + gzip
decode, Brotli is refused. Before the fix, Node 22.19.0 with an earlier
`fetch` failed every request (`invalid onError method`).
- The Docker Node check, run on the compiled files in that layout: Node
22.23.4 and 24.22.1 pass; 22.19.0, 24.21.0 and 26.8.1 fail with the CVE
warning. With `nodeVersion.js` copied alone, as before, it fails with
`Cannot find module '../node/utils/nodeBrotliFix'` (the first
merge-queue run).
- On Electron 44.5.1 (Node 24.21.0), with the compiled main-process
modules: before `new ServiceContainer(...)`, a Brotli response decoded.
After it, plain `fetch` and `fetchDistTags` (the server-update pool)
refused it, gzip responses still worked, and all three wrapped pools
were composed.

## Risks

Low. A server that ignores `Accept-Encoding` and sends Brotli anyway now
fails with "Refused a Brotli-encoded response (CVE-2026-111497)". The
guard is off under Bun and on fixed Node, so `xum server` on a current
Node and the Docker image do not change.

v0.31.1

Toggle v0.31.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
🤖 release: v0.31.1 (#7361)

## Summary

Version bump for the v0.31.1 patch release. It ships the Node2Shell fix
(#7359, CVE-2026-111497) to Stable: today `ghcr.io/coder/mux:latest`
(v0.31.0) runs Node 22.23.3 and decodes compressed request bodies before
auth on a published port. The release also carries every change on main
since v0.31.0. The GitHub Release notes will list the highlights.

## Implementation

Bumped with `node ./scripts/set-package-version.js 0.31.1`, so the root
`package.json` and the legacy `packages/mux-compat` forwarding package
stay version-locked. A stable npm publish uses the `package.json`
version as-is. `src/common/compat/productIdentity.test.ts` passes
locally.

After this PR merges, the GitHub Release `v0.31.1` will be published on
the squash commit. That starts the desktop, Docker (`latest`), VS Code
and npm (`latest`) pipelines.

v0.31.0

Toggle v0.31.0's commit message
🤖 release: v0.31.0

Bump the main and legacy compatibility packages together for the next minor release.

---

_Generated with `xum` • Model: `openai:gpt-6.1-sol` • Thinking: `high` • Cost: unavailable_

<!-- mux-attribution: model=openai:gpt-6.1-sol thinking=high costs=unknown -->

v0.30.0

Toggle v0.30.0's commit message
release: v0.30.0

v0.29.1-nightly.22

Toggle v0.29.1-nightly.22's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
🤖 fix: drop node-pty and the Electron rebuild step, make buildTime re…

…producible (#4309)

The Nix build at `60d40394d` had two problems: `postinstall.sh` always
failed inside the sandbox (no Electron headers, `--ignore-scripts` in
the FOD) and `exit 0` swallowed the failure, and `buildTime` came from
the wall clock so the output was never reproducible.

Whilst looking into the first one, it turned out the rebuild step never
did necessary work. #660 moved us to `node-pty@1.1.0-beta39` and added
`@electron/rebuild` in the same PR, but beta39 is where `node-pty`
switched from `nan` to `node-addon-api`. Node-API is ABI-stable across
Node and Electron, so the rebuild was producing a binary functionally
identical to the prebuild it overwrote. #664 then added
`@lydell/node-pty` as a fallback on the assumption that its Mac
prebuilds weren't built for the Electron ABI; they're Node-API too, and
the desktop app has been loading them inside Electron for SSH, Docker
and devcontainer terminals since that PR landed. On Linux `node-pty` has
no prebuild at all, so Nix and Docker were already on `@lydell` for
every terminal.

So, we'll just keep `@lydell/node-pty`. This removes `node-pty`,
`@electron/rebuild`, `scripts/postinstall.sh`, `make rebuild-native`,
the two-package loader in `ptySpawn.ts`, and the `XUM_HEADLESS` plumbing
in the Dockerfile, flake and benchmark setup. The DuckDB rebuild step
goes too; `@duckdb/node-bindings` has no `binding.gyp`, so it was a
no-op. `asarUnpack` now covers `@lydell/**` so `pty.node`,
`spawn-helper` and the conpty files leave the asar. `@types/node` is
pinned at `^24.9.0` because it was previously hoisted from `electron`
and would otherwise fall back to the Node 20 types.

For `buildTime`, `generate-version.sh` now uses `SOURCE_DATE_EPOCH`,
falling back to the commit time and then the wall clock (Docker builds
have no `.git`). The flake sets `SOURCE_DATE_EPOCH` from
`self.lastModified` so About shows the commit date rather than 1980. The
"reuse existing buildTime" block from #2915 is gone since the existing
`cmp -s` early exit now gives the same HMR stability. Release builds
will show the tagged commit's time rather than when CI ran.

I verified the untouched `@lydell` Linux prebuild from the Nix output
loads and spawns a shell under Electron 40.10.0 and 43.6.0 via
`ELECTRON_RUN_AS_NODE`, and `nix build .#xum --rebuild` passes. The one
path that actually changes behaviour is Local terminals on macOS/Windows
desktop, which move from one Node-API pty prebuild to another of the
same fork. I haven't run `make dist-win`, so Windows desktop is the
least exercised path here.

Unrelated: the `offlineCache` FOD flakes with `Fail extracting tarball
for "mermaid"` in roughly 2 of 3 sandbox runs, with both the old and new
lockfile. I dug into it (`strace`, `curl` inside the sandbox) and it
looks like a bun extraction flake on the largest tarball rather than a
network or lockfile issue. Out of scope for now.

---

_Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking:
`xhigh` • Cost: `$17.46`_

<!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh
costs=17.46 -->