* 🐛 fix(desktop): stop crashing the main process when a user folder cannot be resolved
On Windows, app.getPath('pictures') (and music/videos/downloads) throws
when the Known Folder is redirected or missing. The gateway
system_info_request handler called it without a guard, so sending a
message took down the whole app. Resolve those folders through a
tolerant helper, catch handler failures, and drop unresolved paths
before they reach the prompt template so it falls back to
'(not reported)'.
* 🐛 fix(desktop): always answer failed system-info requests and bound the gateway read
A rejected getShellInfo() or an unguarded app.getPath call used to be
logged without a correlated reply, and getDeviceSystemInfo() posted
without a timeout, so the agent run stayed parked for the lifetime of
the socket. Reply with success: false on failure and apply the shared
device-query timeout so the run degrades to unavailable system info.
* ✅ test(desktop): cover a throwing optional folder lookup in the gateway system-info handler
After a desktop restart both in-memory registries are empty, so
cancelHeteroTask answered "No task found" without exited: true and the
server kept the task unstoppable forever. Look the operation up in the OS
process table instead: confirm the exit when no hetero exec wrapper is
alive, terminate an orphaned wrapper group and confirm only after it exits,
and stay unconfirmed when the lookup fails. Windows keeps today's answer.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(device): register workspaces, allow symlinks, include worktree paths
- include worktree (git.activeWorktree) in approved workspace roots
- register device skill roots in an in-memory cache so previews pass the guard
without polluting the UI's working-directory list
- allow symlinked directories in desktop and CLI file preview check
- detect Windows UNC paths correctly in the path containment check
- normalize filesystem path traversal (../) in the CLI fallback check
* ♻️ refactor(device): use pathe instead of manual win32/posix branch
* 🐛 fix(device): restore missing pathe relative import
Quick Composer used to drop straight into full-screen capture mode. It now
opens as a plain composer: no scrim, no crosshair, prompt focused, and a
text-only prompt can be sent. A camera button in the panel enters the
existing capture flow; Esc backs out of an empty capture pass.
The Screen Recording permission check and window enumeration move from
startSession to a new screenCapture.beginCapture IPC, so the composer works
without the permission. When access is missing the overlay closes before the
prompt, since it sits above the dialog at screen-saver level.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Oilpan keeps freed blink_gc pages committed after large DOM churn, so the
renderer stays at ~3G private while only a few hundred MB is live. Run
HeapProfiler.collectGarbage from the main process when no window has focus
for 30s and a renderer exceeds 1.5G resident (5 min cooldown), and expose the
same call as a GC button in the DevDock memory breakdown.
Claude-Session: https://claude.ai/code/session_01Y6cR5U6xxfWWwJHzeeV5Sa
* ✨ feat(desktop): add built-in completion sound choices
Ship three more synthesized chimes next to the marimba default (glass bell,
soft tone, xylophone) and let the notification settings pick one from a
select; importing a sound now lives inside that select and picking a
built-in drops the imported pack. The generator script renders every
built-in from one instrument × motif table; the default output stays
byte-identical.
Dev builds no longer compute the renderer main hash unless OTA is forced,
since the lockfile pin check only matters for packaged builds.
Claude-Session: https://claude.ai/code/session_01Rf3vs7eC8jwF75kohcS3Ye
* ♻️ refactor(desktop): synthesize completion sounds at build time
Move the chime renderer out of a one-off script into a Vite plugin that
writes the WAVs under the renderer public dir and the banner AIFF under the
desktop resources on every dev/build start, and stop tracking the audio
binaries. Output is byte-identical to the files that were checked in; a
test pins the default chime's hash.
Claude-Session: https://claude.ai/code/session_01Rf3vs7eC8jwF75kohcS3Ye
- add a `copyAssetForPublish` transport (device-control default, desktop IPC
gated by the external-file approval path, device RPC + gateway with
workspace containment on the destination) so the outside-workspace
publish guide can relocate assets on every runtime
- build `copyFile` / `writeFile` for local, device and sandbox files
- let `useBlockedWorkspaceHtmlPublish` hand the confirmed plan to a caller-
supplied `runPublish` instead of always publishing inline
- show a loader instead of spinning the action icon while a toolbar action is busy
Claude-Session: https://claude.ai/code/session_019jVhraxkznG9MtkXaux657
* ✨ feat(device-control): list one project directory on demand
The project index asks Git for ignored paths with `ls-files --directory`,
which reports a fully ignored folder as a single entry. Its children are
never enumerated, so a caller rendering that entry as an expandable row
has nothing to show.
Mark those entries `collapsed` and add `listProjectDirectory`, which reads
one level of a directory from disk. Callers can then fill a subtree only
when someone opens it, so an ignored folder still costs nothing until then
and `node_modules` is never walked.
The flag is only set when the directory truly has no indexed children — a
nested `.gitignore` that re-includes part of the subtree leaves those rows
complete, and they stay unflagged.
Wired for both transports: an Electron IPC method on the desktop
controller, and a device RPC plus TRPC procedure for remote devices. Also
adds `trashLocalFiles`, which moves paths to the OS trash via
`shell.trashItem` rather than unlinking them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🔒 fix(device-control): resolve symlinks before enforcing project containment
`path.resolve` is lexical, so a symlink that lives inside the project but
points outside it passed the prefix check and then had its target
enumerated. Verified against a real temp fixture: listing a link to an
outside directory returned that directory's files.
Check containment on the real paths of both the root and the target.
Entries are still built from the lexical path, so their ids stay anchored
to the path the caller expanded rather than wherever the link resolved —
a link that stays inside the project keeps working and keeps its row.
Also return per-path outcomes from `trashLocalFiles`. A batch is not
atomic, so bailing out on the first failure left the caller unable to
tell which earlier paths were already in the trash, and therefore unable
to refresh its tree or retry only what was left. This matches the shape
`moveLocalFiles` already returns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(portal): guide outside-workspace HTML publishing
* 💄 style(portal): keep external-read consent above footer
* ✨ feat(portal): confirm external file access
* 💄 style(portal): align external-read consent typography
* 🐛 fix(portal): harden outside-workspace publishing
* 🐛 fix: secure external workspace HTML publishing
* 🐛 fix(portal): bound external publish reads and honour local base hrefs
- reject an external publish asset by `stat` size before reading it, on both
the device-control and Electron transports, instead of letting the renderer
find out after a multi-gigabyte base64 round trip
- keep a `<base href>` that escapes the workspace, so its refs are reported as
real outside-workspace dependencies instead of resolving back inside
- drop a local `<base>` from the relocated copy, whose resource hrefs are
already rewritten relative to its own directory
Claude-Session: https://claude.ai/code/session_017KB2isGJ1RDpSaHiv88Xto
* 🔧 chore(deps): register html-artifact in the standalone app workspaces
`apps/cli` and `apps/desktop` enumerate their workspace packages instead of
globbing, so `packages/device-control`'s new `@lobechat/html-artifact`
dependency could not resolve there.
Claude-Session: https://claude.ai/code/session_017KB2isGJ1RDpSaHiv88Xto
* ⚡️ perf(desktop): show the main window on the loading screen's first paint
`ready-to-show` only fires with the `load` event for the desktop entry, which
lands ~250ms after the static loading screen has actually painted. The preload
now reports that first paint (double rAF after `#loading-screen` enters the
DOM) and the main process shows the window on whichever signal arrives first.
Measured on the packaged app (warm start, window creation = 0): window visible
at +140ms instead of +370ms, with the loading screen already painted.
Claude-Session: https://claude.ai/code/session_012z4cpfrWDXZDtNgT4uJKoS
* ⚡️ perf(desktop): enable the V8 compile cache for the main process bundle
Injected as an entry banner because rolldown hoists chunk requires above any
statement in the entry source, so `main-app` would otherwise load uncached.
Main bundle load 55ms → 37ms warm; exec → main.js ~10ms faster on a
packaged build.
Claude-Session: https://claude.ai/code/session_012z4cpfrWDXZDtNgT4uJKoS
* ✨ feat(desktop): show Vite transform progress on the dev loading screens
A cold `bun run dev` sits on the loading logo for 10s+ while Vite transforms
thousands of modules. A serve-only plugin streams the transform count and the
current file over the HMR socket, and an injected client renders it at the
bottom of the window for as long as any boot placeholder (`#loading-screen`,
`#boot-shell`, `#app-shell-fallback`) is on screen.
Claude-Session: https://claude.ai/code/session_012z4cpfrWDXZDtNgT4uJKoS
* 👷 build: upgrade vitest to 5.0.0
Bump vitest and @vitest/coverage-v8 to 5.0.0 and migrate the configs for Vitest 4/5: drop the removed coverage.all option and replace poolOptions.forks.singleFork with maxWorkers/isolate.
* 👷 build: shim jest-dom matcher types for vitest 5
Vitest 5 exposes Assertion<R, T> and no longer reads the jest.Matchers augmentation that @testing-library/jest-dom ships, so its matchers vanish from expect(). Re-declare them on the Vitest interface, and replace the removed describe.sequential with { concurrent: false }.
* 👷 build: migrate the test suite to vitest 5
Vitest 5 (and @vitest/coverage-v8) replaces the esbuild dep optimizer with Rolldown, removed environmentMatchGlobs/coverage.all/poolOptions, forwards `new` to mock implementations, requires top-level vi.mock, and no longer resets automocks in restoreAllMocks. Migrate the root config to app/server projects with Rolldown optimizer options, shim the jest-dom matchers on Assertion<R, T>, and update the affected tests (constructor mocks, hoisted mocks, awaited assertions, explicit mock types, per-test mock defaults).
* 👷 build: make the openapi knowledge-base FileService mock constructable
tests/ was outside the per-area sweep; vitest 5 forwards `new` to the mock implementation, so the arrow implementation threw "is not a constructor".
* 👷 build: point the CI blob-report artifacts at the vitest 5 output dir
Vitest 5 writes blob reports to .vitest/blob instead of .vitest-reports, so the sharded jobs uploaded nothing and the merge jobs failed with ENOENT. Track the new directory in .gitignore too.
* 👷 build: refresh the desktop lockfile for vitest 5
apps/desktop/pnpm-lock.yaml is committed and the desktop build action installs with --frozen-lockfile, so the vitest/override change has to be reflected in it.
* 👷 build: keep the pre-vitest-4 coverage exclusions explicit
Vitest 4+ ships an empty coverageConfigDefaults.exclude, so configs that spread it silently lost the default exclusions (node_modules, dist, *.config.*) and counted more files. Inline the previous list.
* 🎨 style: format the migrated vitest configs
* 👷 build: keep canary's new class mocks constructable under vitest 5
exploreGraph/dispatchAuthorization/scopedDevices (canary #19375, #19352) mock classes with arrow implementations; vitest 5 constructs the implementation when the mock is newed, so the arrows throw 'is not a constructor'.
* ✨ feat(heterogeneous-agent): support Devin CLI
* 🐛 fix(heterogeneous-agent): reuse cached Devin authentication
* ✨ feat(heterogeneous-agent,cli): default Devin ACP to bypass permission mode
- Add `permissionMode` to `DevinAcpSessionOptions` and `SpawnAgentOptions`.
- Update `buildDevinAcpArgs` to place `--permission-mode` before the `acp`
subcommand, since it is a global `devin` flag.
- Default the CLI `lh hetero exec` to start Devin with `permissionMode: 'bypass'`
so headless connected-device runs avoid blocking on permission prompts.
- Support user-supplied `--permission-mode` in `extraArgs`, which takes
precedence and is moved to the correct position.
* ✨ feat(heterogeneous-agent): set Devin ACP session mode via session/set_config_option
The `--permission-mode` global flag only affects the Devin CLI process;
the ACP session's mode still defaults to `accept-edits`, so shell/edit
tools still trigger `session/request_permission` and show askUserQuestion.
After `session/new` or `session/load`, call `session/set_config_option`
with `configId: 'mode'` and the resolved permission mode to actually put
the ACP session into bypass (or user-override) mode. The resolved mode is
derived from the built argv so an explicit `--permission-mode` in extra
args still wins over the default.
Add regression tests for new sessions, resumed sessions, and user override.
* ✨ feat(heterogeneous-agent): apply Devin ACP initial model via session/set_config_option
Previously `initialModel` was only used as a fallback when `session/new`
returned no model. Devin's `session/new` always reports a default model
(e.g. `swe-1-7-medium`), so a user-selected `glm-5-2` was ignored and the
default was used.
Add `applyInitialModel()` to resolve the requested model against the
`configOptions` catalog and call `session/set_config_option` with
`configId: 'model'`. It matches by raw model value and by normalized
human-readable name (so 'glm 5.2' maps to 'glm-5-2'). When only a raw
`initialModel` is provided and the catalog is unavailable (e.g. resume),
it attempts to set the value directly.
Also preserve the updated model after setting the permission mode so the
final `devin_session` event reports the selected model.
Add regression tests for model selection, label matching, default
preservation, and combined permission mode + model setup. Update the
`spawnAgent` fake to expose a 'sonnet' option for the new behavior.
* ♻️ refactor(heterogeneous-agent): stop injecting --permission-mode bypass CLI flag
Devin ACP permission mode is now applied through `session/set_config_option`
(configId: 'mode'), which is the only reliable way to change the active ACP
session mode. The global `--permission-mode` CLI flag before the `acp`
subcommand does not change the ACP session's effective mode.
- `buildDevinAcpArgs` no longer injects a default `--permission-mode`.
- It still moves a user-supplied `--permission-mode` in `extraArgs` before
the `acp` subcommand for backwards compatibility.
- `DevinAcpSession.resolvedPermissionMode` falls back to
`options.permissionMode` so the default CLI `bypass` is still sent via ACP.
- Updated tests to expect `acp` without the default flag and assert the
`session/set_config_option` mode request.
* fix(devin): do not default to bypass permission mode and protect selected model from mode response
- Remove the hard-coded `permissionMode: 'bypass'` default for Devin in
`lh hetero exec`. Users can still pass `--permission-mode` via
`--agent-arg` when needed.
- Stop using the `session/set_config_option` mode response to overwrite the
model resolved from `applyInitialModel`. Permission-mode negotiation can
return config-options whose `model` value is stale/default, causing a
freshly selected model (e.g. `glm-5-2`) to be replaced with the Devin
default (`swe-1-7`) on `stream_start`.
- Update CLI tests to no longer expect the bypass default.
Refs LOBE-xxx (feat/devin-cli).
* fix(devin): keep default bypass permission mode while protecting selected model
Re-introduce the default `permissionMode: 'bypass'` for Devin ACP so
headless connected-device runs still do not block on permission prompts.
The previous change that removed it was too aggressive; the real bug is
that the `session/set_config_option` mode response was overwriting the
selected model. That guard in `DevinAcpSession` remains in place.
Refs LOBE-xxx (feat/devin-cli).
* ✨ feat(heterogeneous-agents): capture and emit Devin ACP usage as turn_metadata
- Forward `session/prompt` result `usage` from `DevinAcpSession` into the
`devin_prompt_completed` synthetic event.
- Add generic ACP `Usage` parsing to `TraeAcpAdapter`, plus `usage_update`
handling with a protected `extractUsageFromUsageUpdate` hook.
- Override `extractUsageFromUsageUpdate` in `DevinAcpAdapter` to parse
`_meta.cognition.ai/{input,output,cachedRead,cachedWrite,thought}Tokens`.
- Emit `step_complete` with `phase: 'turn_metadata'` so the frontend reducer
writes `usage` (and optional `cost`) onto the assistant message.
- Add `UsageData.cost` to mirror `MessageMetadata.usage`.
- Add `devinAcp.test.ts` covering prompt-result usage, `usage_update` _meta,
and fallback paths.
* ✅ test(heterogeneous-agents): add missing request params type in spawnAgent test fixture
* 🐛 fix(heterogeneous-agent): cancel device-side CLI process on user stop
Previously only remote-task hetero types (openclaw / hermes) triggered a
`cancelHeteroTask` dispatch to the device when the user clicked Stop.
Local-cli types (claude-code / codex / devin / trae / qoder) that also
run on a remote device via `dispatchAgentRun` were silently skipped —
the UI showed cancelled but the CLI process kept running.
Two fixes:
- Server `interruptTask`: drop the `isRemoteHeterogeneousType` guard so
any hetero type with a deviceId in runningOperation triggers the cancel
dispatch.
- Desktop `spawnLhHeteroExec`: register the spawned child process into
`platformTasks` via a new `onChildSpawned` callback so `cancelHeteroTask`
can find and kill it by operationId. Also add `detached: true` so the
process-group kill reaches the CLI without signalling the desktop app.
* 🐛 fix(cli): cancel lh connect device CLI process on user stop
The previous cancel fix only covered the desktop device path. When a
local CLI agent (devin/claude-code/codex/…) is dispatched through
`lh connect`, the spawned `lh hetero exec` child was never registered
into the task registry, so `cancelHeteroTask` dispatched from the
server returned "No task found" and the remote process kept running.
- `agentRun.ts`: spawn with `detached: true` and register the child PID
into `taskRegistry` on spawn, keyed by operationId. Remove the entry
on exit only if the exiting PID still owns the task.
- `taskRegistry.ts`: widen `TaskEntry.agentType` from
`'hermes' | 'openclaw'` to `string` so local CLI agent types can be
persisted.
- `heteroTask.ts`: `cancelHeteroTask` now signals the whole process
group via negative-PID kill (Unix) or `taskkill /T /F` (Windows),
with a 2s SIGINT→SIGKILL escalation — mirroring the desktop path.
* 🐛 fix(cli): add cancel-trace logging to diagnose device CLI stop path
Add [cancel-trace] logs at every hop of the cancel path:
- server interruptTask: logs when dispatching cancelHeteroTask, when
condition is not met (with runningOp/targetOperation details), and
the dispatch result/error
- agentRun.ts: logs when task is registered into taskRegistry, and
when it is removed on exit (or skipped due to stale PID)
- heteroTask.ts cancelHeteroTask: logs when received, when task is
found or not found, and each kill attempt
* 🐛 fix(server): seed deviceId/heteroType into runningOperation for local CLI hetero agents
Local CLI hetero agents (devin / claude-code / codex / …) dispatch to a
device via `dispatchAgentRun`, but `childOperation` only embedded
`deviceId`/`heteroType` for remote platform agents (openclaw / hermes)
guarded by `isRemoteHetero`. Without these fields in
`topic.metadata.runningOperation`, `interruptTask` could not find the
target operation and never dispatched `cancelHeteroTask` — so clicking
Stop in the web UI did nothing for `lh connect` devices.
Patch `runningOperation` with the resolved `dispatchDeviceId` and
`heteroType` after a successful local CLI hetero dispatch, handling
both root and child (group supervisor) operation shapes.
* 🔥 chore: remove [cancel-trace] debug logs
Clean up the temporary diagnostic logs added to trace the cancel path.
Keep only the production-level `interruptTask` and `execAgent` logs that
were already present or are useful for long-term observability.
* 🐛 fix(heterogeneous-agent): preserve cancel lifecycle state
* ♻️ refactor(heterogeneous-agent): simplify Devin ACP integration
* 🐛 fix Devin intervention review flow
* 🐛 fix heterogeneous agent cancellation
* 🐛 fix heterogeneous agent cancellation confirmation
* 🐛 fix(heterogeneous-agent): preserve Devin permission option IDs
* 🐛 fix: resolve CI failures after rebase
* 🐛 fix: route Devin verification and permissions
* 🧪 test(cli): isolate agent run cancellation registry
* 🐛 fix(cli): cancel full live agent process group
Cloud is moving from app.lobehub.com to lobehub.com. The desktop default
cloud server now resolves to the apex; OFFICIAL_URL is left untouched so
web metadata, the CLI default server and branding links keep the legacy
subdomain until they migrate on their own schedule.
Also widen the subscription webview's Oidc-Auth injection filter, since
`https://*.lobehub.com/*` does not match the bare apex.
Claude-Session: https://claude.ai/code/session_01HkdNNBVJRFvhr9PmqZYe6e
Turn on Electron's `scrollBounce` webPreference so macOS elastic overscroll
works inside the app. Inner scroll containers (message list, sidebars, the
lobe-ui ScrollArea viewport) already default to `overscroll-behavior: auto`,
so they pick this up without any CSS change.
Also drop the `overscroll-behavior: none` on the `.app` container. It was
copied verbatim from `src/styles/global.ts` when AppTheme.tsx was created in
#13933, and `.app` is a child of `#__next` that is not a scroller, so the rule
never did anything there. The root-level rule in `src/styles/global.ts` stays:
it is what suppresses Chrome Android pull-to-refresh, which fires from the
root scroller regardless of whether the root can actually scroll.
Claude-Session: https://claude.ai/code/session_01UfQZPkguhjsXwvuqdySHB2
* 🐛 fix(desktop): derive renderer OTA compatibility from source inputs
* 🐛 fix(desktop): set up Node before validating S3 releases
* 🐛 fix(desktop): report OTA decisions and preserve diagnostics
* ✨ feat(desktop): show a page thumbnail when hovering a tab
Tab hover only carried the title, so telling two chats apart meant switching
into each one. Cache a small JPEG of the tab's content area and render it in
the hover tooltip.
The shot is taken while the tab is still on screen rather than at switch-away
time: `capturePage` resolves against whichever frame the compositor services
next, which is already the incoming tab. A trailing throttle driven by pointer
activity and a MutationObserver keeps the cache within a couple of seconds of
what the user last saw, and costs nothing while the page is quiet.
Content-area only — `NavPanelShell` sits outside `DesktopLayoutContainer`, so
the TabHost pane's own rect already excludes the sidebar and the title bar.
Measured on a 1236x890 content area: 9-17ms per capture end to end, 2-3KB per
thumbnail, capped at 24 entries.
Claude-Session: https://claude.ai/code/session_01TiYYa6ceUxVVtvjuJGbR4t
* ♻️ refactor(desktop): capture tab previews on tab bar hover instead of polling
The throttle + MutationObserver kept every visible pane capturing on a 2s
cadence whenever anything moved, and still missed layout-only changes such as
split view resizing. The preview is only ever read after the pointer enters the
tab bar, so take the shot right there: pointer enter precedes any click that
swaps tabs, which sidesteps the compositor race without a background loop.
Keyboard switches reuse whatever was cached last.
Claude-Session: https://claude.ai/code/session_01FbdJPhxcGe99wE55VawRJh
* ✨ feat(desktop): add chat completion sound settings
Split completion feedback by window focus so the two sound sources can never
overlap: the foreground plays an in-app chime (off by default, own volume and
importable audio), while the background leaves the sound to the notification
banner. The main process owns the focus decision and the renderer only plays
when the banner was skipped or refused.
The banner can carry either the macOS default sound or LobeHub's own chime.
UNNotificationSound resolves a name only against ~/Library/Sounds and only in
AIFF, so the chime is installed there on demand rather than bundled.
Claude-Session: https://claude.ai/code/session_01SaZzuGjGHqADBxhWn4HjUa
* ✅ test: mock completion sound in agent run specs
The hover popover drew three bordered buttons inside a bordered panel — nine
edges for three actions, and wider than the file chip it belongs to. The three
buttons are now plain segments in one group; the popover border is the group
border, with 1px hairline dividers that clear on hover.
Show in Folder opened the parent directory via shell.openPath(dirname), leaving
the file unselected. Files now go through shell.showItemInFolder, which reveals
and selects them; directories keep openPath.
Claude-Session: https://claude.ai/code/session_01SYxzWZKa6Xjf5qcunKJyPL
- server: emit X-Auth-Failure (jwt_expired / jwt_signature / user_inactive / no_token ...) next to X-Auth-Required on session UNAUTHORIZED responses
- desktop: log every 401/403 through the backend proxy at info level with hadToken, authRequired, authFailure and locally decoded JWT claims (sub / exp / expiredBy)
- desktop: add scripts/analyzeAuthLog.mjs (bun run log:auth) that turns a user's main.log into verdict codes with next steps
Claude-Session: https://claude.ai/code/session_01XjdtgxABquDMKeLZxu8q64
* ✨ feat(devdock): turn the memory readout into a popover with a memory-infra breakdown
The bar readout showed private / freed / JS heap numbers with nothing to explain
the gap between the renderer footprint and the V8 heap. The readout is now a
button (`R<private> · J<heap>`) that opens a floating popover with a live overview
(private / shared / resident / reclaimable, V8 heap + committed + malloced, Blink),
a 5-minute history chart, an on-demand memory-infra dump captured through the
sender's CDP debugger and rendered as an expandable allocator tree with Blink
object counts, and the per-process table from app.getAppMetrics().
Claude-Session: https://claude.ai/code/session_017p3xQsD6G8a4GoUvqyMBj3
* fix: break DevDock memory widget cycle
* 🐛 fix(file): reserve storage before upload
* 🐛 fix(file): gate storage quota on upload paths that skip reservations
The reservation flow only covers pre-signed tRPC uploads. Three surfaces write
`files` rows without ever consulting the quota: the OpenAPI upload endpoint,
server-side image generation, and bot-channel attachment ingest.
- OpenAPI upload checks the quota once per path that actually adds bytes, and
maps the rejection to a 403 instead of a generic 500. Re-requesting a record
the caller already owns adds nothing, so it stays exempt.
- Image generation admits at submission rather than at persist — blocking after
the model call is already paid for is worse than not blocking.
- Attachment ingest charges the post-compression buffer, and a quota skip now
says so instead of leaving the sender to guess.
Claude-Session: https://claude.ai/code/session_014iyxmqssnrPopmKV1xKJ7k
* 🐛 fix(file): release reservations on every dataset import failure
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
* 🐛 fix(file): stop reservations from deleting pre-existing objects
Reject a reservation whose pathname already holds an object, so aborting one
can only ever delete a key it created. Serialize server-owned uploads with the
owner-row lock by threading the transaction through the quota check and the
file row it authorizes.
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
* 🐛 fix(file): keep the S3 write out of the quota transaction
Holding the owner-row lock across the upload blocked every other write to that
user for the length of the transfer, and Postgres kills the transaction outright
wherever DATABASE_STATEMENT_TIMEOUT is set, orphaning the object it had already
written. Check the quota once before spending the transfer, upload outside any
transaction, then re-check under the lock the file row is written with.
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
Move upload SHA-256 hashing out of the renderer main thread. On web the
stream hasher runs in a Web Worker and reports progress; on desktop the
renderer hands the local path to the Electron main process, which hashes
with node:crypto. In-memory files without a path keep the worker path.
The upload dock shows the hash progress while pending.
Claude-Session: https://claude.ai/code/session_01LJkJWSibCK5rne6GfdH79d
On macOS the allocator returns freed pages with madvise(MADV_FREE_REUSABLE),
which stay in the resident set until the kernel needs them. workingSetSize
therefore keeps reporting gigabytes after a leak is fixed while the private
footprint has already dropped. Report the renderer's resident set from the
same getAppMetrics() call and surface resident − private − shared as
Reclaimable next to the private footprint.
Claude-Session: https://claude.ai/code/session_01F7A3FbhSVhm2xz12dR7VSN
* 🐛 fix(hetero): stop a slow login shell from reading as a missing CLI
A Kimi Code task ran successfully, then its retry failed with "Kimi Code CLI
was not found. Install it and make sure `kimi` can be executed" — for a binary
that was installed, executable, and had just produced a PR.
The CLI is resolved by reading PATH from a login *interactive* shell, which has
to load the user's full interactive config first. On the machine that hit this
it measures 1.26–1.32s idle, against a 3s timeout — barely more than twice the
cost, and the probe runs precisely when the machine is busy doing the work that
needed the CLI. The second attempt ran while an agent session, a dependency
install and a test suite were competing for the box; it spent the budget,
`catch` returned undefined, and `kimi` was not on the leaner PATH a
Finder-launched Electron inherits.
Three things made that inevitable rather than unlucky:
- The resolved PATH was discarded the moment it settled, so it was re-read on
*every* spawn — the call site detects with `force`, so nothing else absorbed
it. It is cached for the process now, with `invalidateLoginShellPathCache()`
wired to `detectAll`/`detectCategory`'s `force`, which is what Rescan uses.
A probe that completed is cached even when it found nothing, because that is
a real answer; a probe that *timed out* is not, because that is the transient
case and pinning it would claim there is no login PATH until restart.
- Three seconds was below what a normal dev machine needs under load. Ten, and
with caching only the first spawn pays it at all.
- A timeout was indistinguishable from "not installed", so the error sent the
user to reinstall working software. `cli_detection_timeout` says what
happened and points at retrying or setting an absolute path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(hetero): invalidate the PATH cache from the rescan users actually press
The first version hooked invalidation onto `detectAll` / `detectCategory`, but
the Connect Agent Rescan button goes through
`BinaryCtr.detectHeterogeneousAgentCommand`, which calls
`detectHeterogeneousCliCommand` directly and never touches `BinaryManager`. So
a user who installed a CLI and pressed Rescan still got the PATH cached at
first scan — the exact failure the cache was supposed to be safe against, and
the same mistake as wiring a helper nothing calls.
Invalidation now sits on `BinaryManager.detect`, the one method the sweeping
variants both route through, and on the direct controller path. Hooking the one
funnel rather than each caller is what makes it hold for the next entry point
too.
A rescan landing while a probe was already out could also repopulate the cache
with the pre-rescan PATH: `.then()` wrote unconditionally, and the ten-second
window makes that overlap realistic. Probes now carry a generation and a
disowned one cannot populate or satisfy the fresh scan.
Two things surfaced once the suite could run. A pre-existing test asserted the
old contract — every scan re-reads the shell — and now covers the same
installer-edit scenario through an explicit invalidation, which is where that
freshness lives. And the cache is module-scoped, so it leaks between tests the
way it persists across a process: the suite resets it per case, which two
unrelated Amp tests needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(desktop): release IPC listeners and hidden editor roots
* 🚨 chore(lint): ban renderer IPC removeListener
* 🗑️ chore(lint): remove ESLint config tests
* 🐛 fix(editor): destroy unmounted chat input kernels
* 🐛 fix(editor): restore hidden roots to their owners
* 🐛 fix(editor): keep the root when Lexical drops it first
Read the root from our own ref as well, so the cleanup no longer depends
on Lexical still holding it — a ref-callback teardown runs before this
passive cleanup and would skip the destroy. Key the destroyed flag by
editor identity so a replacement editor is still destroyed and a second
cleanup generation cannot destroy twice.
Cover the lifecycle against the real editor: the registry leak without
the hook, direct visible unmount, hide, reveal, and hidden unmount.
Claude-Session: https://claude.ai/code/session_01FP977j6GhQbVp4E2gWL8Wc
* 📈 feat(agent-runtime): record device vs transport time for dispatched tools
A tool dispatched to a device takes ~2s, and today nothing says how much of
that is the tool and how much is getting there and back. The server can only
measure the whole round trip — publish → gateway → device → callback →
redis — and the device never reported its own duration, so the two are
indistinguishable in every trace we have.
That split is the input to a real decision: whether to move the agent loop
onto the device. If most of the 2s is transport, local execution collapses it
to a function call. If it is not, the same effort is better spent on the
server-side work each tool step already does (two message writes plus a full
`messages.query()` refetch) — an order of magnitude cheaper, and no runtime
port needed.
The device now times `executeToolCall` on its own clock and returns
`executionTimeMs`; the server carries it to the trace beside its own
observation. `lh trace op inspect` renders the split:
└─ Tool local-system ✓ 1.9s (device 40ms + transport 1.9s)
Since the step's own `executionTimeMs` brackets the whole tool step, the
remainder — step minus round trip — is the server-side message-write and
query cost, so all three numbers come out of one trace with no further
instrumentation.
Note the server's tool-result endpoint validates with `z.object`, which
strips unknown keys: the field had to be declared there or it would have been
dropped silently between gateway and redis. Everything is optional end to
end, so an older device, or an agent-gateway that does not yet forward the
field, degrades to the total alone rather than breaking. The CLI also logs
the duration in its daemon log, which is what lets us tell those two cases
apart once this is deployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(desktop): report device tool timing from the desktop gateway too
Only the CLI responder was instrumented, so every tool dispatched to the
Electron app fell back to "total only" and could not produce the
device-vs-transport split. Desktop is where most device tool calls actually
happen, which would have biased the whole measurement toward the `lh connect`
subset — exactly the runs least representative of real usage.
Both desktop routes are timed (builtin tool and tunnelled stdio MCP), on the
success and failure paths alike: a tool that takes 30s to fail is as
interesting as one that takes 30s to succeed.
The repo has exactly two `sendToolCallResponse` call sites outside the client
itself; both are now covered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(desktop): hash renderer OTA main bundles
* 🐛 fix(desktop): hash externalized runtime modules in mainHash
Externalized main-process modules (electron-log, font-list, native addons
and their dependency trees) never reach the emitted bundles, so a renderer
OTA patch could be gated against an installed binary whose external runtime
module had changed.
Each platform now contributes an `externals` digest: name@version for
registry packages, plus full source content for workspace packages whose
version never bumps.
Claude-Session: https://claude.ai/code/session_01HNtnp2V5PrHc6tgrjwLRwN
* ♻️ refactor(desktop): unify update notification info
* test: avoid building main hash bundles in unit test
* ⚡️ perf(test): stop loading antd in the global vitest setup
setupFiles re-evaluate per test file, so `import { theme } from 'antd'` —
present only to set `theme.defaultConfig.hashed = false` — cost every one of
the 1836 root-suite files ~596ms of module evaluation.
Nothing asserts on unhashed antd class names, so the flag can go with it.
Full root suite: 396.95s -> 283.88s wall, setup 2027s -> 256s, no test changes.
Prebundling antd via `deps.optimizer` was measured as an alternative and
rejected: a prebundled module resolves to the optimizer chunk while
`vi.mock('<bare-specifier>')` registers on the bare id, so the mock silently
stops applying (23 suites started reading the real implementation).
Claude-Session: https://claude.ai/code/session_01K9fwqjBqMYMsYDddo9b4JN
* ✅ test(desktop,cli): lift per-file logger mocks into the setup files
52 desktop suites and 38 CLI suites each carried an identical
`vi.mock('…/utils/logger')` whose only job was silencing output — the desktop
logger also pulls electron-log and `@/env` in at module scope.
Register one stub per app instead:
- desktop `__mocks__/setup.ts` stubs `createLogger`
- new `apps/cli/tests/setup.ts` spreads the real module and replaces every `log`
method with a spy, so suites that assert on log calls keep working
Suites needing different behavior still override with their own `vi.mock`;
`logger.test.ts` in each app opts out via `vi.unmock`.
desktop 122/122 pass. CLI unit failures go 9 -> 8 (all pre-existing;
`src/settings/index.test.ts` now passes).
Claude-Session: https://claude.ai/code/session_01K9fwqjBqMYMsYDddo9b4JN
* 🐛 fix(test): make model-runtime stream tests actually assert
Three suites drove a stream end to end and then asserted only that something
existed, so they stayed green against a broken implementation.
- zhipu `handleStream`: 8 cases built tool_call chunks, read the whole stream
into `chunks`, then asserted `expect(chunks).toBeDefined()`. Driving
`instance.chat()` with a mocked `create` yields `choices: []`, so the
transform under test never ran. Rewritten to call
`params.chatCompletion.handleStream` directly and assert the emitted SSE
`event: tool_calls` indices — 365 -> 147 lines, and the
all-placeholders branch is now covered.
- google `should handle text messages correctly`: the mock enqueued a bare
string, which actually throws `Cannot use 'in' operator to search for
'promptFeedback'` once the body is read. Now enqueues a real candidates
chunk and asserts the SSE text payload.
- azureOpenai `should handle o1 series models without streaming` asserted only
that `transformResponseToStream` ran, never that o1 forces `stream: false`.
Each was mutation-verified: deleting the negative-index fix, the placeholder
filter, the google thought filter, or the o1 stream override now fails these
tests and previously did not.
186 files / 4650 tests pass.
Claude-Session: https://claude.ai/code/session_01K9fwqjBqMYMsYDddo9b4JN