Electron's net stack (SimpleURLLoaderWrapper, used internally by
electron-updater's net.request) emits transient connectivity errors
(ERR_NETWORK_CHANGED, ERR_NETWORK_IO_SUSPENDED) on Wi-Fi/VPN switch and
system sleep. With no global guard they bubble up as an uncaughtException
and pop the "A JavaScript error occurred in the main process" dialog.
Add a process-level handler that swallows known transient net-stack
errors and re-throws everything else, so genuine crashes stay visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(cli): add `lh update` command to self-upgrade the CLI
Add a `lh update` command that checks the npm registry for the latest
published version of `@lobehub/cli`, compares it against the installed
version, and runs the matching global-install command. The package
manager is auto-detected (npm/pnpm/yarn/bun) from the running binary
path, with `--package-manager` to override. Supports `--check` for a
dry run and `--tag` to target a non-latest dist-tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(cli): resolve update command package metadata from the bundled entry
`update.ts` loaded `require('../../package.json')`, a path relative to its
source location. tsdown bundles everything into `dist/index.js`, where that
runtime require resolves to `node_modules/@lobehub/package.json` instead of
`@lobehub/cli/package.json` — so a published build threw at startup the moment
the update module was registered, breaking every `lh` invocation.
Reuse the single package-metadata load already done by the bundled entry
(`program.ts`, at the depth where `../package.json` is correct both in source
and in dist). Export the package name alongside the version and consume both
in the update command. Verified against a real `tsdown` build: `lh --version`
starts cleanly and `lh update --check` resolves `@lobehub/cli` correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(cli): extract package metadata into a shared src/pkg.ts const
Importing `cliVersion`/`cliPackageName` from `program.ts` into `update.ts`
created a `program ↔ update` import cycle (program registers the update
command). Move the single `require('../package.json')` load into a dedicated
`src/pkg.ts` const module that both `program.ts` and `update.ts` import,
breaking the cycle. The module sits directly under `src/` — the same depth as
the bundled `dist/index.js` — so `../package.json` resolves to `@lobehub/cli`'s
own package.json in both source and bundled runs.
Verified against a real tsdown build: one `createRequire("../package.json")`
remains, `lh --version` starts cleanly, `lh update --check` resolves the
package, and `man:generate` still reads the version via the re-export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(cli): use the semver package for update version comparison
The hand-rolled comparator ordered prerelease identifiers lexicographically,
so `1.0.0-beta.10` sorted below `1.0.0-beta.9` and `lh update --tag beta`
reported "already current" instead of offering the newer prerelease.
Replace `compareSemver` with `isNewerVersion`, delegating to the `semver`
package (already a workspace dependency) so numeric prerelease parts compare
numerically. Add `semver` / `@types/semver` to the CLI package and a
regression test for the beta.9 → beta.10 case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A parked run (`waiting_for_human` / `waiting_for_async_tool`) is not finished —
it is paused waiting out-of-band and resumes under a new operation. The client
lifecycle wrongly drove parked states through the terminal path: it emitted a
`client.runtime.complete` signal (mis-encoding `waiting_for_human` as a terminal
`cancelled`) and let `waiting_for_async_tool` fall through with an undefined
complete status.
Route parked states to `onRunParked` (no longer NOOP) instead of `completeRun`:
- `waiting_for_human`: complete the op so the approval spinner clears; a new op
resumes the run.
- `waiting_for_async_tool`: keep the op running until the async result drives it.
- neither fires terminal side effects (title / queue drain / notification /
markUnread) nor emits `client.runtime.complete`.
Drops the `waiting_for_human → cancelled` mis-encoding from the normalizer and
the parked branch from `completeRun` (parked never reaches it). This makes the
client lifecycle a correct shared implementation before gateway/hetero reuse it.
Characterization updated to lock the corrected behavior. LOBE-10382.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🗃️ feat(database): combine workspace-device + ai_infra surrogate PK migrations (0111)
Merge the two staged workspace-scoped DB rollouts that both claimed 0111
into a single migration, since they touch disjoint tables:
- ai_infra (LOBE-10056 Phase 5): ai_providers / ai_models move from their
composite PK to a surrogate `_id` uuid PK, with business uniqueness moved
to workspace-scoped partial unique indexes (personal WHERE workspace_id IS
NULL, workspace WHERE workspace_id IS NOT NULL).
- device + workspace (LOBE-10315): replace the full (user_id, device_id)
unique with two partial uniques scoped by workspace_id; add workspace
frozen / frozen_reason / frozen_at columns; DeviceModel gains an optional
workspaceId plus registerWorkspaceDevice / queryWorkspaceDevices /
findWorkspaceDeviceById / updateWorkspaceDevice / deleteWorkspaceDevice.
The combined 0111 SQL stays hand-written and idempotent: a NO-OP on cloud
production (ai_infra side was applied online via manual steps; device/frozen
adds are all guarded) and a full rebuild on fresh / self-hosted databases.
`bun run db:generate` reports no drift; device model tests pass.
Supersedes #15533 and #16004.
Co-Authored-By: rdmclin2 <rdmclin2@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🗃️ feat(database): add verify_evidence table (0112)
Captured artifacts backing a verify_check_result — screenshot / gif /
video / text / dom_snapshot / transcript — with storage key, media
metadata (mime/width/height/duration/size/checksum), provenance
(captured_by / captured_at) and a self-referencing parent_evidence_id
that forms an evidence tree (ON DELETE set null keeps children).
- types: add verifyEvidenceTypes + verifyEvidenceCaptureSources to the
verify domain vocabulary in @lobechat/types.
- schema: verify_evidence table with check_result / user / workspace
cascade FKs and FK-lookup indexes.
- 0112 SQL is idempotent (CREATE TABLE IF NOT EXISTS, DROP+ADD FK
constraints, CREATE INDEX IF NOT EXISTS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Revert "🗃️ feat(database): add verify_evidence table (0112)"
This reverts commit 45a253b8f8.
---------
Co-authored-by: rdmclin2 <rdmclin2@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(topic): persist topic unread as a backend status
Move the "unread completed generation" indicator from a client-only
Zustand Set to a persisted `topics.status = 'unread'`, so it survives
reload and syncs across devices.
- add 'unread' to the topic status enum / type / updateTopic zod / sort rank
- compute cross-agent unread counts server-side on the sidebar agent list
(SidebarAgentItem.unreadCount) so the home badge stays accurate for
agents whose topics aren't loaded on the client
- markTopicUnread (formerly markUnreadCompleted) writes status 'unread';
markTopicRead (formerly clearUnreadCompletedTopic) flips it back to
'active' when the user opens the topic
- partition the terminal status write by (viewing, succeeded) so the
background completion's 'unread' write never races the 'active' write
- derive unread selectors + sidebar status bucketing from topic.status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(topic): clear unread on route hydration; don't mark parked/cancelled runs unread
Address two review findings on the persisted unread status:
- Route hydration (reload / deep link / notification) sets activeTopicId
directly instead of going through switchTopic, so the persisted
'unread' was never cleared while the user was already reading. Add
useClearActiveTopicUnread — once the active topic appears in a loaded
bucket as 'unread', mark it read. Wired into agent + group ChatHydration.
- A gateway agent_runtime_end with reason 'interrupted' / 'waiting_for_async_tool'
is a cancel / park, not a completion. Gate markTopicUnread on
isCompletedRuntimeEnd(reason) and derive onSessionComplete's `succeeded`
from the same predicate, so aborts/parks clear back to 'active' instead
of persisting as an unread completion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(topic): hoist topic status enum into @lobechat/types + add TopicModel tests
- extract the inline status z.enum in the topic router into a shared
chatTopicStatusSchema / TOPIC_STATUSES source of truth in @lobechat/types
- derive ChatTopicStatus from the schema so the type and validator stay in sync
- add TopicModel unit tests covering CRUD, status filtering/ordering, triggers,
metadata merge, duplicate, batchMoveToAgent and memory-extractor queries
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Synthetic local agent-eval / smoke userIds (`eval_*`, `qstash_smoke_*`) are
never real Market accounts, so Market rejects any trusted-client token built
from them with `invalid_trust_token / Invalid userId format`. The rejected
round-trip is non-fatal (prep calls are best-effort) but noisy and adds
noticeable prep latency during evals.
generateTrustedClientToken now returns undefined for these ids before signing,
avoiding the doomed request. Real userIds are unaffected (no real id carries
these prefixes).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A slow `git commit` (running a lint-staged hook) makes Claude Code track the Bash
call as a task and emit `task_started` + `task_notification` back-to-back, with no
out-of-band callback turn in between, immediately followed by the tool_result —
i.e. an ordinary inline synchronous tool, not a Monitor-style long-running task.
The adapter armed `pendingTaskCompletion` on every `task_notification`, so the
next normal turn (which simply consumes that commit's tool_result and continues
working) got stamped `signal.type = 'task-completion'`. Downstream, a signal-
tagged turn is anchored off the source tool and kept off the spine, while the
following turn re-anchors to the pre-task assistant — the continuation forks off
the main chain and disappears from the rendered topic. Observed on
tpc_joZS2mksoY5L (two `git commit`s, two dropped turns) and tpc_SZXNHtiNeqxv.
Fix at the source: only arm `pendingTaskCompletion` when the ending task actually
fired callback turns while alive (`callbackCount > 0`). That is the signature of
a genuine long-running task whose completion produces a post-task summary (the
summary is meant to render after the preceding callbacks). A task that starts and
ends with zero callbacks was an inline tool — leave the next turn untagged so it
stays a normal main-chain step. Erring this way is safe: a mis-classified genuine
summary merely renders inline instead of disappearing.
Regression test: `task_started` + `task_notification` with no intervening callback
→ the next turn carries no `externalSignal`. The existing Monitor flow (a callback
fires before `task_notification`, `callbackCount > 0`) still tags `task-completion`.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
🐛 fix(model-runtime): converge second residue wave + unmap payload-too-large from ECW
Second pass over the UpstreamHttpError fallback residue. Add ~40 message
patterns so the recognizable remainder gets a precise code instead of the
bare-HTTP bucket:
- ModelNotFound: invalid model identifier, incorrect model ID, integrator gating
- PermissionDenied: 403 Forbidden, Azure VNet/firewall, channel client restriction
- UserConfigError: routing 404 / "No route for that URI" / url.not_found (base-URL misconfig)
- InvalidRequestFormat: provider-side validation + custom-proxy schema bridges
(reasoning/thinking type, Gemini INVALID_ARGUMENT, function_declarations, pydantic field errors)
- ExceededContextWindow: genuine "the model's context length" overflow
- CapabilityNotSupported: image inference unsupported, no-SSE
- InsufficientQuota: paid-only model, credits exhausted, CodingPlan
- ProviderNetworkError: undici "fetch failed"
- UpstreamGatewayError: bare HTML error bodies
Also unmap `Request body too large` from ExceededContextWindow: a payload / 413
size limit is not the model context window. It stays in the bare-HTTP bucket.
Tests: +second-round cases + a guard that payload-too-large is NOT ECW.
`bunx vitest run src/errors` → 103 passed.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(device): auto-activate a device for bot triggers on a local target
A bot conversation has no UI to pick an execution device, and a stored
`local` target (in-process IPC) is unreachable from the cloud bot server,
so a bot-triggered run on a `local`-target agent would stay
`device-unrouted` and never touch the owner's machine.
Upgrade a stored `local` target to `auto` when `trigger === bot`, so the
run auto-activates an online device (single → use it; several → stay
unrouted and let the model pick via the remote-device tool). `none`
(explicit no-device) and `sandbox` (explicit cloud) are deliberate
opt-outs and stay; an explicit `requestedDeviceId` already pins a device;
chat mode still wins (plain chat). Only the `bot` trigger fires this — a
`chat`/`cron` run on a `local` target is unchanged.
Plumbs `trigger` into `resolveExecutionPlan` and both call sites (native
+ hetero dispatch) in execAgent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(device): rename misleading `isDesktop` to `clientExecutionAvailable`
The `isDesktop` option on `resolveExecutionTarget` / `resolveExecutionPlan`
never meant "the build is desktop" nor "the message came from a desktop
client". It gates whether the `local` target has somewhere to run — i.e.
whether tools can execute on the user's own client/device (`runtimeMode:
'local'`, the `'client'` executor), as opposed to a pure-cloud context that
only reaches the sandbox. It is the boolean form of `RuntimePlatform`
('desktop' vs 'web'); each layer feeds the value that means this for it:
`isDesktop` (build const) in the UI, `gatewayConfigured` on the server,
`hasDeviceProxy` in the tools engine.
`isDesktop` was misleading on two counts: it implied the desktop build, and
"local"/"desktop" are perspective-relative (the server is never "local" /
"desktop"). `clientExecutionAvailable` names the user-side capability
directly, so `!clientExecutionAvailable` reads unambiguously as "no client to
run on" regardless of where the resolver runs. It gates only `local`; an
explicit `device` binding is reachable from anywhere and is unaffected.
Pure rename + doc clarification, no behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(device): honour a bound local device for bot runs
The bot-trigger upgrade relabelled every `local` target to `auto`, but the
device switcher persists the desktop's own `deviceId` as `boundDeviceId` when
the user picks `local` ("this device"). `auto` deliberately ignores
`boundDeviceId`, so an owner's bot message for a pinned-local agent could
auto-grab whichever single device was online — or go ambiguous with several —
instead of the machine the user chose.
Upgrade a bot `local` target to `device` (route to the pinned machine) when it
carries a `boundDeviceId`; only an UNBOUND `local` falls through to `auto`. An
offline pinned device now stays `device-unrouted` (bound-device-offline)
rather than silently running elsewhere — same no-silent-fallback rule the
`device` target already follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs(hetero-agent): document live-trace capture & field-classifier debug flow
Capture the recurring "read the recorded trace, then check the hetero
implementation against it" workflow in the heterogeneous-agent skill so it
doesn't have to be rediscovered each time.
- references/debug-workflow.md §2: the in-app live-trace recorder
(HeterogeneousAgentCtr.createCliTraceSession) — when it records, where it
writes (cwd/.heerogeneous-tracing vs appStoragePath when the toggle is on),
the per-session layout (meta/stdin/stdout/stderr/exit), and the
.last-live-trace fast path. Prefer it over a hand-rolled `claude -p` repro.
- references/debug-workflow.md §8: verify a structured-field classifier
against a real trace — grep the discriminator field across all event states,
not just the failing one. Worked example: the CC usage-limit fix, where
rate_limit_info rides on status:"allowed" events too.
- SKILL.md: point the debug order at the live trace and add the
wrong-terminal-error-guide bug pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs(hetero-agent): fix mangled reproduce-session sentence
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(chat): allow audio upload in agent mode and render audio with a waveform player
- Unify the upload UI media gate with the store's agent-mode bypass: in agent
mode / heterogeneous agents the gate no longer drops audio/video/image the
agent can still process (e.g. .m4a on a non-audio model).
- Backfill the audio mime from the file extension on upload, since .m4a shares
the ISO-BMFF container with mp4 and the browser / byte-sniffing can report an
empty or video/mp4 mime, which mis-classified it as a non-audio file.
- Replace the native <audio> in user messages with a self-contained waveform
player (play/pause, click-to-seek, decoded peaks with a deterministic fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♿️ a11y(chat): lazy-load audio waveform and label the play/pause control
- Defer waveform fetch/decode until first playback (or seek) instead of on mount,
and skip decoding files over 20MB, so opening a conversation with several audio
attachments no longer downloads/decodes every clip and spikes network/memory.
- Give the toggle a stateful accessible label (Play audio / Pause audio) with
aria-pressed, keeping the filename as a title tooltip, so screen readers announce
the action rather than the file name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(device): inject bound working directory into server local-system tool chain
Server/gateway-mode device runs ignored the bound working directory
(workingDirByDevice): the local-system tool's systemRole prompt
({{workingDirectory}}) and the daemon both saw the device default cwd
(= `/` for a Finder/Dock-launched app), so the agent didn't know where
the repo was and ran commands from `/`.
Core fix: resolveWorkspaceInit already loads the persisted device row and
resolves the bound directory (resolveDeviceWorkingDirectory: topic override
> workingDirByDevice > device.defaultCwd) for its workspace scan. Return
that boundCwd and write it onto deviceSystemInfo.workingDirectory, which
fills the {{workingDirectory}} prompt placeholder — the channel the model
uses to know where it is and reach for absolute paths. Sourced from the DB
row (works offline), not a live queryDeviceSystemInfo() call (which only
reports the daemon's process.cwd = `/`); that field is dropped from the
template fetch. Resume-safe via the existing deviceSystemInfo plumbing.
The model can't pick cwd/scope, so two ops still need a runtime default
injected into the device-proxy args: runCommand (manifest hides `cwd`,
daemon spawns in process.cwd() when omitted) and the search ops
(searchFiles/globFiles/grepContent, whose `scope` daemon-side falls back
to process.cwd() despite the manifest claiming it defaults to the working
directory). File ops (read/write/edit/move/list) get absolute paths from
the prompt and need nothing injected.
LOBE-10440
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(device): resolve relative file-op paths against bound working directory
Server/gateway mode follow-up: local-system file ops (readFile / writeFile /
editFile / listFiles / moveFiles) now resolve a relative path against the
device-bound working directory instead of the daemon's process cwd (= `/` for a
Finder/Dock-launched app), so a model-supplied relative path lands in the bound
directory. `executeToolCall` only forwards `arguments`, so `cwd` rides in the
args: the server proxy injects it (WORKING_DIR_ARG) and the daemon resolves it
via a new shared `resolveAgainstCwd` in `@lobechat/local-file-shell` (absolute
paths and the no-cwd case are unchanged → no regression for desktop client-mode).
Also returns `resolveWorkspaceInit` as a dedicated `ResolvedWorkspaceInit`
({ boundCwd, workspace }) instead of an ad-hoc intersection, keeping `boundCwd`
out of the persisted/UI-shared `WorkspaceInitResult`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(device): drop unexposed listFiles from working-dir injection
listFiles was removed from LocalSystemManifest.api (99023811d8), so the
proxy never builds a listFiles function. Drop it from WORKING_DIR_ARG and
the test, swapping in moveFiles which is still an exposed file op.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(devices): add recent-directory button to device detail panel
Add an always-visible "+" action to the right of the "Recent directories"
title in the device settings detail panel. Previously the add affordance was
gated on canBrowse (current desktop device only), leaving remote / non-current
devices with no way to add a directory — which looked like a bug.
The button adapts per device: native folder dialog when browsable, otherwise
reuses the control bar's openAddWorkingDirModal with deviceService.statPath
validation on the target device. The redundant bottom button is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(devices): extract AddWorkingDirModal into shared WorkingDirectory feature
Move AddWorkingDirModal out of ChatInput/ControlBar into a neutral
src/features/WorkingDirectory domain feature, so the device settings panel no
longer reaches into the chat control bar to reuse it. Both the control bar
picker and the device detail panel now import it from @/features/WorkingDirectory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(devices): load the device i18n namespace for recent-dir errors
The detail panel's useTranslation only declared the `setting` namespace, so the
inline `{ ns: 'device' }` overrides for the add-directory validation errors
relied on `device` being loaded elsewhere — on the standalone settings page it
may not be, rendering the raw key. Declare both namespaces and reference the
device keys with the `device:` prefix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A later unrelated terminal failure (e.g. an ECONNRESET network drop) was
inheriting the last allowed rate_limit_event's rolling-window metadata and
rendering a bogus "usage limit reached, resets at X" guide.
CC stamps `rate_limit_info` onto its events even when the request goes
through (`status: 'allowed'`) — that block is the rolling-window metadata
(`resetsAt`, `rateLimitType`), not evidence the limit was hit. Now require
BOTH `status: 'rejected'` AND a concrete reset window before classifying a
failure as a user-side quota limit.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(cli): stop connect daemon on logout
`lh logout` only cleared the credentials file, leaving the `lh connect`
daemon running. It kept the device online on the gateway using the cached
token until expiry, so the machine stayed remotely driveable after the user
thought they had logged out, and left stale daemon.pid / daemon.status.json
behind.
Tear down the daemon via stopDaemon() before clearing credentials, which
SIGTERMs the daemon (disconnecting the gateway) and removes the pid/status
files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔒 fix(cli): verify daemon identity before SIGTERM
A bare isProcessAlive() check let logout/connect-stop SIGTERM an unrelated
process when the OS reused a dead daemon's PID after a crash or reboot.
getRunningDaemonPid() now also confirms the live PID is actually a LobeHub
daemon (matches the `connect … --daemon-child` command-line signature) and
treats reused PIDs as stale, cleaning up the metadata instead of killing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔖 chore(cli): bump @lobehub/cli to 0.0.32
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(cli): add top-level `disconnect` command
`lh connect` was only stoppable via the nested `lh connect stop`, which is
hard to discover. Add `lh disconnect` as a top-level alias that runs the same
daemon-stop handler, so users have an obvious inverse of `lh connect`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(device): hide current machine's duplicate entry from remote device list on desktop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(device): add explicit `auto` execution mode — never auto-activate a device unless opted in
Auto-activation used to be an implicit default: an unbound run on any
device-capable target (incl. the desktop `local` default) silently grabbed
the single online device. So an agent the user never pointed at a device
would still activate one — and a `none` selection was the only way to opt out.
Introduce `auto` as an explicit, opt-in mode and make it the ONLY mode that
auto-activates an unbound device:
- `none` → plain chat, never a device
- `auto` → one online device is activated automatically; with several, the
run stays unrouted so the model picks one via the remote-device
tool. Ignores any stale stored `boundDeviceId` (that's `device`
mode) — only an explicit `requestedDeviceId` pins a device.
- `local` → strictly this desktop machine
- `device` → the explicitly bound device
- `sandbox` → cloud sandbox
`local` / `device` no longer auto-grab a device: an unbound run stays
`device-unrouted` until one is bound/requested. Platform defaults are
unchanged (desktop `local`, web `none`) so no migration is needed — existing
configs simply stop auto-activating; users opt into it by picking `auto` in
the execution-device switcher.
Adds the `auto` option + chip to HeteroDeviceSwitcher (non-hetero only) and
en/zh copy. Updates resolveExecutionPlan + tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(device): show this machine as a named device row with a local tag
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix: restore gateway device option
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(model-runtime): classify upstream-message fallbacks out of UpstreamHttpError
Harvest ~30 message patterns from the agent-gateway error dashboard's
UpstreamHttpError / ProviderBizError fallback bucket. These are 4xx errors that
carried no recognized message, so refineErrorCode demoted them to the bare-HTTP
UpstreamHttpError bucket. Classify them into precise codes instead:
- ExceededContextWindow: oversized request body, 1m-context gating, /compact hint
- ProviderServiceUnavailable: Vietnamese/Chinese "system busy" + upgrade notices
- ModelNotFound: ollama allow-list, Vertex publisher-model, CN model-not-online
- PermissionDenied: proxy bans, Codex-only group, trial-user gating
- InvalidRequestFormat: illegal params, bad tool schema, malformed proto, negative max_tokens
Provider-agnostic, so custom proxies benefit too. Classification only — it does
not suppress anything; messages stay visible under a precise code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(model-runtime): make UpstreamHttpError fallback re-refinable
UpstreamHttpError is itself a catch-all — the status fallback emits it for a 4xx
whose message matched nothing — but it was excluded from PATTERN_REFINABLE_CODES,
so once an error was demoted to it, it could never be upgraded again.
formatErrorForState is idempotent and re-enriches an already-normalized error, so
a code demoted to UpstreamHttpError on an inner pass was frozen there even when
the message became recognizable. Add UpstreamHttpError to the pattern-refinable
set so a later pass (or a historical batch-rewrite) can still upgrade it. Bare
500 is already covered via InternalServerError. Pattern-only — UpstreamHttpError
is intentionally kept out of the HTTP-status fallback to avoid re-bucketing loops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs(ux): add empty-state fallback + context-aware default tab principles
Distill this branch's agent-document fixes into the ux skill: persistent chrome
still needs a body empty state (§1.1), and a multi-tab surface's landing tab
should follow entry intent + data state rather than a hardcoded first tab (§1.5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-documents): honor skill entry for default tab & enforce metaReadOnly at the store
Two review fixes for the agent-document panel:
- Default tab: when the open doc is a skill (SKILL.md or any file inside a
skill bundle), land on the Skills tab even when normal documents exist.
Previously the default only checked whether any document existed, so the
skill-entry flow opened on the wrong tab. Derived from the current docId's
list item category.
- metaReadOnly enforcement: gate setTitle / setEmoji / performMetaSave in the
PageEditor store instead of only disabling the TitleSection UI. The
page-agent title handlers route editTitle, applyServerSnapshot, and initPage
title extraction through the store's setTitle, so a managed SKILL.md could
still be renamed (and its filename clobbered via DocumentService.updateDocument)
by AI/extraction paths. The store is the single chokepoint covering every
caller. Adds tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(hetero-agent): assistant-anchored message-chain write spine (LOBE-10445 phase 2)
Switch the heterogeneous-agent write side from the fragile "next assistant
parents off the run's last tool" rule to an assistant-anchored spine:
`user → assistant → assistant …` with tools as inline children. Phase 1's
role-aware reader already reads both shapes equivalently, so this moves
chain-linearity off the distributed write path and onto a deterministic,
DB-authoritative anchor.
- mainAgentCoordinator: add `lastSpineMessageId` (the most recent non-tool /
non-signal main message). Normal turns parent off it; signal / reactive
toolless turns (Monitor stdout callbacks) keep parenting off
`lastToolMsgIdEver` so the reader still renders them as tool-child callbacks.
The spine advances on normal turns only — never on a signal turn — so a
normal continuation after a callback burst re-mounts on the pre-callback
spine assistant, not on a callback (which the reader would skip, orphaning
everything after it).
- subagentCoordinator: stop advancing `lastChainParentId` to the batch's last
tool; it stays at the current assistant (subagents have no signal turns).
- MessageModel: replace the seed-scoped last-tool anchor query
(`getLastMainThreadToolMessageIdSince` + `getLastChildToolMessageId`) with
`getLastMainThreadSpineMessageId(topicId)` — latest `threadId IS NULL`,
`role != 'tool'`, `metadata->'signal' IS NULL`. Reading the spine straight
from the DB is fork-resistant: it does not depend on the in-memory
current-assistant pointer, which can regress to the run's seed on a cold
serverless replica (the remote "断链" fork). Consecutive cold-replica steps
now chain linearly instead of forking onto a stale node.
- HeterogeneousPersistenceHandler: recover `lastSpineMessageId` from the new
query in `refreshMainStateFromDb`; drop the whole tool-anchor recovery block,
the `seedAssistantMessageId` state field, and its helper methods;
`buildSubagentSnapshot` recovers `lastChainParentId` as the thread's current
assistant.
Tests: heterogeneous-agents 256, server hetero 92 (cold-replica anchor test
rewritten to assert a linear, fork-resistant spine), database message model 63
(incl. spine query: excludes tools / signal callbacks / subagent threads, user
scoped). Type-check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(hetero-agent): align client executor tests to the assistant-anchored spine
The client `heterogeneousAgentExecutor` consumes the shared
`@lobechat/heterogeneous-agents` reducer, so the write-side spine switch in this
PR also changes client behavior — but the client tests still asserted the old
"next assistant parents off the run's last tool" rule and failed in CI
(Test App shard 1/2, 9 failures). Update them to the spine rule:
- multi-step / Codex multi-turn / full E2E: step assistants chain off the most
recent non-tool main message (the spine), with tools inline children — not
off the preceding turn's last tool.
- subagent terminal assistant: chains off the in-thread assistant (the subagent
reducer keeps the chain anchor on the assistant, not the tool), so the thread
reads user → asst(tools) → asst(result).
- Monitor parentId chain: reactive Monitor stdout steps arrive as ordinary CC
message steps with no `task_started`/`task_notification` signal context, so
under the spine rule they parent off the previous spine assistant and render
as plain spine assistants. (Genuine signal turns — `external signal` describe
— stay tool-anchored and are unchanged.)
Behavior-only test alignment; no production code change. 70/70 pass, type-check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(chat): collapse FloatingChatPanel into a slim strip by default
The doc-portal side chat now starts as a one-row input strip instead of a 180px
sheet, freeing space for the document. A hover-revealed expand button and the
existing Send hook both lift it into a 420 / 800 snap sheet; drag-to-dismiss or
the new header chevron-down collapse back. The ChatInput's chrome change
(showControlBar + actions) on each toggle is crossfaded via the View Transition
API, so its height shift no longer reads as a jump while the sheet animates.
The panel wrapper now owns the rounded surface; the FloatingSheet renders
seamlessly inside it so the sheet and input share one continuous card.
* ✨ feat(chat): wire FloatingChatPanel into the full-page agent doc route and shrink the collapsed input to one line
The standalone agent-document view (`/agent/:aid/docs/:docId`) now anchors the
same side-chat panel at the bottom under the same lab toggle. The chat surface
no longer has to live on the in-chat portal alone.
Conversation `ChatInput` gains a `compact?: boolean` prop. When set, the panel
wrapper collapses the rendered chrome to a one-row strip via a CSS focus-within
clamp — action bar, control bar, footnote stay rendered but are hidden until the
user focuses the editor. The strip expands smoothly on focus and collapses
again on blur; the prop defaults to false so every other chat surface is
unaffected.
* 💄 style(chat): clamp the full-page FloatingChatPanel to the conversation width
The standalone agent-document route hosted the panel at the editor's full width,
so its rounded surface read as one giant card. Reuse `WideScreenContainer` (same
clamp as the document body) so the panel inherits the surrounding content width
and centers below the page.
* 💄 style(chat): drop the FloatingChatPanel surface while collapsed
The wrapper's border and background made the one-row strip read as a card even
when no sheet is open. Hide both while collapsed and let the strip sit flush
against the page; the border + bg fade back in over 240ms as the sheet rises.
* ✨ feat(chat-input): drop the action bar footer when ChatInput is compact
The previous compact wrap clipped the editor with a CSS max-height; that cut
straight through the editor's rounded border and the result looked truncated.
Push the compact contract into the renderer instead: DesktopChatInput accepts
a `compact` prop and skips passing a footer to the underlying editor, so the
single-row strip ends on the editor's own rounded bottom edge with no clip.
Enter still sends, which is the only entry point the FloatingChatPanel strip
exposes while collapsed.
* ✨ feat(chat): expand the collapsed strip into the full ChatInput while focused
The strip only dropped the action bar based on the panel's collapsed state, so
focusing the editor never restored Send / actions. Track focus-within on the
input row and recompute `compact = renderedCollapsed && !focused`; the action
bar (and control bar / left+right actions) returns on focus and folds back on
blur, with each transition wrapped in the same View Transition crossfade as
the collapse / expand toggle.
* ✨ feat(chat): gate the FloatingChatPanel expand bar on focus instead of hover
The "展开聊天" pill used to surface whenever the cursor entered the strip,
including while the input was still in its one-row compact state. Move the
trigger to focus: while the strip is collapsed and unfocused (compact) the bar
stays hidden; focusing the editor expands the input AND reveals the pill, so
the affordance only shows once the user has actually engaged with the input.
* ✨ feat(chat): anchor the doc-side chat to a per-document topic, not the active one
The FloatingChatPanel used to inherit whatever `useChatStore.activeTopicId`
happened to hold when the panel mounted. On the full-page document route that
landed it on a stale or empty topic; on the in-chat portal it tied the
"doc-side" chat to the user's current main conversation. Replace both with a
doc-anchored topic provisioned per `(documentId, agentId)` pair.
Server
- New `agentDocument.getOrCreateChatTopic` procedure. Idempotent: returns the
existing topic when one is already linked via `topic_documents` for this
agent + document pair; otherwise creates a topic with
`trigger='document'` (titled from the document) and inserts the
`topic_documents` association.
- `TopicModel.findByAgentAndDocumentTrigger` joins through `topic_documents`
for the lookup half of the get-or-create.
- `'document'` is now part of `MAIN_SIDEBAR_EXCLUDE_TRIGGERS` and the recent /
topic-list system trigger filter so these auto-provisioned topics stay out
of the user-facing topic surface.
Client
- `useDocumentChatTopic({agentId, documentId})` wraps the procedure with SWR
so re-mounts share the resolved topic id.
- `AgentDocumentPage` (full-page route) and `Portal/Document/Body` (in-chat
portal) both gate panel rendering on the resolved topic id and pass
`scope='main'`.
- `FloatingChatPanel` branches on `scope`: `'main'` hands message loading off
to `ConversationProvider` (no `skipFetch` / `hasInitMessages` / external
messages), `'thread'` keeps the existing self-managed slice for backward
compat. Thread-anchoring logic only runs under `'thread'`.
* 🔥 refactor(chat): drop thread-scope branch from FloatingChatPanel
Both call sites now resolve a doc-anchored topic and render through the
main-scope path. With no remaining `'thread'` consumer there is no reason for
the panel to keep the dual code path: the thread-anchoring (`portalThreadId`
sync, `sourceMessageId` lookup, `isNew` / `threadType`), the private message
slice (`dbMessagesMap` filter, `replaceMessages`, `onMessagesChange`), and
the `scope` / `threadId` props are all removed. The panel is now main-scope
on the supplied topic id, full stop, and `ConversationProvider` owns message
loading.
Tests collapse to the main-scope shape — the thread-scope assertions and the
related chat-store mocks go away.
* 🔥 refactor(context-engine): remove ActiveTopicDocumentContextInjector
With doc-anchored topics, every turn in a topic is by definition about the
linked document — the chat never crosses scopes, so the per-turn `<document>` +
"use lobe-agent-documents, not PageAgent" guidance is now redundant.
Removed
- The `ActiveTopicDocumentContextInjector` provider and its Phase 4 wiring in
`MessagesEngine`.
- `RuntimeActiveTopicDocumentContext` plus `RuntimeInitialContext.activeTopicDocument`.
- The server-side `aiAgent.execAgent` block that resolved the agent-document
row from `appContext.documentId` to populate `activeTopicDocument`.
- The client-side `resolveActiveTopicDocumentInitialContext` resolver plus its
unit tests and the call sites in `conversationLifecycle` / regenerate.
Kept
- `mergeAgentRuntimeInitialContexts` — still needed for mentioned-agents and
ad-hoc tool manifest merging. Moved to a focused
`src/store/chat/utils/runtimeInitialContext.ts` module.
- The `documentId` / `agentDocumentId` fields on `ConversationContext` — the
downstream `lobe-agent-documents` tool calls still read them for direct row
lookups, just without the per-turn guidance prologue.
* 🐛 fix(chat): pin the AgentDocumentPage panel to the URL agent, not the active one
The full-page document route reads its agent from the URL params (`aid`), but
the FloatingChatPanel was pulling `useAgentStore.activeAgentId` first and only
falling back to the URL value. Whenever the user's globally-active agent
didn't own the document being viewed, `agentDocument.getOrCreateChatTopic`
threw NOT_FOUND on the `findRowByDocumentId(activeAgent, doc)` lookup, the
hook never resolved a `topicId`, and the panel silently never rendered.
Use `aid` directly — the route already owns the agent identity, so the lookup
hits the agent that genuinely owns the document.
* 🩹 chore(chat): log failures in useDocumentChatTopic so the panel's silent gating is debuggable
The panel renders only when the doc-anchored topic id resolves, so any
procedure error currently disappears into SWR. Surface failures through
`debug('lobe-chat:useDocumentChatTopic')` and a plain `console.error`, and
log skipped / resolved transitions, so a missed topic id can be traced to
either the input pair or the server call from the browser console.
* 🐛 fix(chat): sync the FloatingChatPanel through the shared chat store, not just its local slice
The doc-scoped refactor handed message loading off to `ConversationProvider`'s
internal store, but the lifecycle's `chatStore.replaceMessages` (called after
`aiChat.sendMessageInServer` resolves) only writes to the *global* chat
store's `dbMessagesMap`. The panel never observed that map, so the user's
message and the streamed assistant response landed in the database but the
panel UI stayed empty.
Wire the same `messages` / `onMessagesChange` / `hasInitMessages` loop the
main `ConversationArea` uses: read `dbMessagesMap[chatKey]` from the chat
store, pass it as the external `messages` prop, and write back through
`replaceMessages` on changes. `ConversationProvider` then mirrors the
canonical slice in its local store and the panel re-renders the moment the
lifecycle persists.
* Revert "🔥 refactor(context-engine): remove ActiveTopicDocumentContextInjector"
This reverts commit 9419ee7de4.
* 💄 style(floating-chat-panel): drop side/bottom borders in expanded state
Only keep the top border (aligned with the drag handle) so the panel
no longer competes with adjacent sidebar borders.
* 💄 style(chat): lead collapsed tool-call summary with total call count
Move the total call count to the front of the folded workflow summary and
drop the "total" wording, merging the tool-kind count into the list as a
trailing "across N tools". Reads as "15 calls: … across 6 tools · 1 failed".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🌐 i18n(chat): use a fresh key for the tool-list suffix
The suffix wording changed meaning (standalone "N tool kinds" → appended
"across N tools"). Reusing summaryMoreTools would leave other locales with
stale standalone grammar, since auto-i18n only fills missing keys. Rename to
summaryAcrossTools so every locale is missing it and CI translates the new
copy; until then those locales fall back to the English default rather than
broken grammar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The "clear directory" picker action wrote a workingDirByDevice map with the
device key removed, but both the client optimistic store and the server
persist path deep-merge, which can only add/overwrite keys — never drop one.
The cleared entry was re-merged from the prior value, so the selection
survived and the button looked dead.
Carry the clear as an explicit `undefined` marker and prune undefined-valued
keys after each merge (client `internal_dispatchAgentMap` + server
`updateConfig`), mirroring the existing `params` undefined-delete pattern.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(prompt): rework input completion prompt to v1.2 — predict intent, incl. long-range
Real tab-accept rate for input_completion is ~4% (positive / all feedback incl.
neutral implicit-ignore). But the fix is not "abstain by default" — that is too
conservative and blocks the long-range completion we actually want. v1.2 reframes
the engine around predicting the user's intent, the way inline code completion does:
- Lead with prediction: complete the rest of the phrase, the sentence, or the
next sentence or two when the conversation makes intent clear. Favor a useful
continuation over a timid one-word guess.
- Abstention is now a narrow safety valve, not the default: empty only when the
natural continuation would be the assistant's voice, would fabricate unknown
specifics, or there is no signal. The "complete sentence -> empty" rule is gone
(sentence-end cancellation belongs in the app layer, not the prompt).
- Voice guard kept and made semantic (you are finishing the user's message, never
replying); length cap relaxed from <8 words.
- Examples are synthetic and English-led (match-user-language instruction stays);
no verbatim trace text.
Context engineering:
- History is rendered as a flat, speaker-labelled <conversation> block rather than
replayed as real assistant/user turns — replaying invites the role-flip failure.
- Filter to user/assistant non-empty messages, keep the last 8 turns, clip each to
1000 chars so one long turn can't drown the draft and inflate latency/cost.
- Draft sits last (recency) inside a <draft> block with an explicit <|cursor|>
marker, folded with context into a single user message. System prompt stays
constant so the tracing promptHash keeps grouping by version.
* ✨ feat(eval-rubric): feed task input into the llm-rubric judge prompt
The llm-rubric matcher only sent [Criteria]/[Output] to the judge, so the judge
never saw the task input it was scoring against (the input the answer-relevance
matcher already receives). Thread `input` through the dispatch into the judge
user prompt as an [Input] block — without it the judge scores an output in a
vacuum.
Also refactor matchLLMRubric to a single object param, now that it takes
input/actual/expected/rubric/context.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(prompt): tune v1.2 input-completion — default-short + stay-in-user-voice
Backtest showed ~3.2x over-generation vs the length users actually accept. Bias
the autocomplete toward that length: default to finishing the current phrase or
sentence, demote long-range continuation to an explicit exception, and drop the
anti-"timid" nudge. Reinforce user-voice under heavy context (never resolve or
decide on the assistant's behalf) while clarifying that the user asking the
assistant a question is still the user's voice (prevents over-abstention).
Prompt version stays v1.2 (still unreleased).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(eval-rubric): retry the LLM judge and keep real 0 scores
The llm-rubric matcher treated a valid score of 0 as "no score" (`!result.score`) and auto-failed on a single missing/errored judge response. Check the score type instead, and retry up to `judgeMaxAttempts` (default 3) on a thrown error or unparseable response so one flaky sample can't reject a good output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(prompt): v1.3 input-completion — kill the assistant-voice role-flip
When the draft tells or asks the assistant to act ("那你改下?", "你能否用 cli 验证?"), the model would complete in the assistant's voice ("我先把…修掉", "我这边可以…吗"). Disambiguate the first person (user's 我 vs assistant's 我), forbid 我先/我来/我这边/我这就/I'll/let me when the draft addresses the assistant, prefer empty for already-complete directives, and reinforce it in the schema description + examples. Lifts the avoid-assistant regression set from ~43% to ~97% on gpt-5.4-mini.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(prompt): input-completion v1.2 — English-only assistant-voice role-flip guard
v1.2 is not released yet, so keep the version label at v1.2 (revert the premature v1.3 bump) and fold the fix into it. Prompt is English-only (no Chinese). Disambiguate the first person — both the user and the assistant say "I", so the role decides who is speaking; when the draft tells/asks the assistant to act, do not continue as the assistant accepting the task ("I'll…", "let me…"), prefer empty for already-complete directives. Reinforced in the schema description + examples. On a 25-case avoid-assistant regression set, gpt-5.4-mini passes ~92-96%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: add product glossary page
Add a user-facing glossary of LobeHub concepts (agents, topics, threads, skills, memory, models, billing, and more) at docs/usage/glossary, with terms organized by initial letter and EN + zh-CN versions.
Point the existing developer translation-consistency table (docs/glossary.md) to the new page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: move glossary to top-level docs/glossary
Move the product glossary from docs/usage/glossary to top-level docs/glossary (EN + zh-CN). Relocate the developer translation-consistency table to docs/development/internationalization/glossary and point it at the new /docs/glossary page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: make glossary terms headings so they appear in the page TOC
Promote each term to an h3 heading (under its letter h2) so it shows in the On This Page outline, remove the redundant Jump-to row, and repoint synonym cross-references to the specific term anchors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(docs): stop @ tokens autolinking to GitHub in glossary
Wrap literal @mentioning and @LobeHub in inline code so the docs MDX mention-autolinker no longer turns them into github.com/<name> links.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: remove CoT, Discover, DM, and Hands-Free Mode glossary entries
Also drops the now-orphaned Marketplace synonym stub (it only pointed to Discover) and the empty H letter section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add frozen / frozen_reason / frozen_at columns to the workspaces table with an idempotent migration. Backs the cloud workspace-freeze risk-control feature; OSS column with no desktop behavior attached.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(model-bank): rename model type `stt` to the standard `asr`
`asr` (Automatic Speech Recognition) is the conventional term for the
speech-to-text model category, matching the modern model-runtime `transcribe`
API and the `asr.transcribe` tRPC procedure. Standardize the model-type
taxonomy on `asr` across the type schema, model data, UI, API contract and
i18n — without a bulk DB data migration.
Application layer (full rename, no behavioral impact — `type` is only a
category label; nothing branches on it at runtime):
- model-bank: `AiModelTypeSchema` enum `stt` → `asr`; `AISTTModelCard` →
`AIASRModelCard`; `openaiSTTModels` → `openaiASRModels`
- UI: provider ModelList tab/count/pricing-case/create-form options + community
ModelTypeIcon map
- aihubmix: map the platform `stt` identifier to LobeHub `asr`
- i18n: rename `providerModels.tabs.stt` / `type.options.stt` keys (default +
en-US + zh-CN); other locales left to the auto-i18n CI
Persistence — runtime migration instead of a SQL migration ("don't fetch, don't
touch"):
- read-time mapping: the aiInfra repository normalizes any residual `stt` row to
`asr` on read (builtin models already get their type from model-bank)
- write-time heal: `AiModelModel` create/update/toggle/batch persist `asr`, so
data lazily migrates whenever a row is naturally written; new writes are `asr`
- shared `normalizeAiModelType` helper in model-bank
OpenAPI — non-breaking:
- input still accepts `stt` as a deprecated alias (no 4xx for old clients) and
normalizes it to `asr`; responses always emit `asr`; type filter matches both
`asr` and legacy `stt` rows
No `ai_models` data migration is shipped; old rows stay valid and convert lazily.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(cli): sync `lh model` type inputs with the `stt` → `asr` rename
`stt` was removed from `AiModelTypeSchema`, so the CLI paths that still
advertised and forwarded `--type stt` broke for users following the help text:
`lh model create/edit --type stt` was rejected by the router schema and
`lh model list --type stt` returned no ASR models.
Advertise `asr` in the `--type` help for list/create/edit, and normalize the
legacy `stt` alias to `asr` before forwarding to the API / filtering results,
so existing scripts keep working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-documents): harden document explorer tree and skill index titles
- Dedupe tree nodes by bare name so a folder/file name clash no longer crashes the panel
- Show & rename the skill bundle title for SKILL.md index docs
- Default the right panel to the Skills tab when an agent has no plain documents
- Add an empty-state placeholder (with reachable toolbar) for the documents tree
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(agent-documents): lock skill index page meta instead of renaming via agentDocumentService
Editing the title on a SKILL.md index page routed through the generic
agent-document rename API for the parent bundle, which rejects managed skill
documents (METHOD_NOT_SUPPORTED) — so the skill name never updated. Worse,
PageEditor's own meta save had already written the title to the index row,
overwriting the SKILL.md filename and desyncing the bundle.
Add a `metaReadOnly` flag to PageEditor (threaded through provider → store →
TitleSection) that locks the title/emoji while keeping the body editable, and
set it for skill index docs. The page now shows the bundle title read-only; the
index row is never clobbered. Renames go through the skill APIs elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🔥 refactor(stt): remove deprecated /webapi/stt route and frontend mic entry
The /webapi/stt/openai route relied on the deprecated @lobehub/tts server
helper and createBizOpenAI. Voice input via this path is barely used, and
ASR is now served by the modern `asr.transcribe` TRPC procedure
(model-runtime based, multi-provider, fileId upload). Drop the legacy
speech-to-text UI entry points and the webapi route.
- Remove the ChatInput mic (STT) action and unwire it from all chat inputs
(Thread, FloatingChatPanel, GroupChat DM)
- Delete the /webapi/stt/openai route and its API_ENDPOINTS.stt constant
- Remove the STT service/auto-stop settings panel and the OpenAI whisper
STT model option; keep all TTS settings intact
- Drop the now-dead per-agent sttLocale field from AgentTTS
Persisted setting fields (sttServer/sttAutoStop/sttModel/sttLocale) are kept
dormant to avoid a settings-schema migration; can be cleaned up separately.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 fix(linear): render get_issue result as a card instead of raw JSON
Single-entity Linear endpoints (get_issue, save_issue, …) return the
entity directly, often embedding empty sub-collections like
`documents: []`. The render's `extractResultRecords` matched any
`RESULT_ARRAY_KEYS` key, so an empty `documents: []` was treated as the
result set, yielding zero entities and falling back to the raw-JSON
"Raw result" view. Now only `list_*` endpoints scan nested collections;
single-entity results keep the object as the entity so it renders as a
card. Also dedupe the `status` field that duplicates the state tag and
trim ISO timestamps to a compact form.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 fix(linear): don't use UUID id as card title; show full date+time
Comments/attachments have no human title, only a UUID id — the render
promoted that UUID to the big card title (duplicating the id tag below).
Now the title block only renders when a real title exists; the id stays a
secondary tag (linked when a url is present) and the comment body carries
the card. Also format timestamps as `YYYY-MM-DD HH:mm:ss` instead of `HH:mm`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(linear): preserve search result cards via structural unwrap
The previous `verb === 'list'` guard stopped unwrapping search wrappers
like `{ results: [...] }` — and Codex routes Linear search through a bare
`search` apiName that parses to `verb: 'other'`, so a verb allowlist can't
catch it either. Switch to a structural check: unwrap a nested collection
only when the object itself has no identity (no id/title/name/subject), so
list/search wrappers expand into cards while single entities (get/save/
create/fetch-one) keep their embedded sub-collections from hijacking them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(model-runtime): add ASR (transcribe) support and route CLI asr through tRPC
- add `transcribe` method to model-runtime (BaseAI, openai-compatible factory,
ModelRuntime, RouterRuntime) with ASRPayload / ASROptions / ASRResponse types
- add `asr.transcribe` tRPC procedure that resolves the provider key via
initModelRuntimeFromDB (server env fallback included) and returns { text }
- rewire CLI `generate asr` to call the new tRPC endpoint instead of
/webapi/stt/openai, dropping the @lobehub/tts dependency for CLI ASR
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(model-runtime): implement Gemini-native transcribe (ASR via multimodal generateContent)
Google/Gemini has no OpenAI-compatible audio/transcriptions endpoint, so ASR is
done through the native multimodal API: audio is passed inline alongside a text
instruction and the model returns the transcript as plain text.
- add `createGoogleTranscription` (src/providers/google/transcribe.ts): inline
base64 audio + prompt → `client.models.generateContent` → trimmed text;
mime resolved from blob type, then fileName/file.name extension, then default
- wire `transcribe` onto LobeGoogleAI with the same error mapping as `chat`
(abort detection + parseGoogleErrorMessage)
- unit tests for transcript trimming, language hint, mime inference, error mapping
Verified end-to-end against the real Gemini API (gemini-2.5-flash via AiHubMix):
transcribed the same .m4a correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(model-runtime): use Gemini Files API for large audio transcribe
Inline audio is capped at Gemini's ~20MB request limit. Audio over ~14MB raw
(base64 inflates ~4/3) now uploads via the Files API, polls until the file is
ACTIVE, then references it by URI; smaller audio stays inline as before.
- add `uploadAudioFile` (files.upload → poll files.get until ACTIVE →
createPartFromUri) with timeout + abort handling
- unit tests for the Files API path and the PROCESSING→ACTIVE poll loop
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(server): support fileId in asr.transcribe and document the base64 transport
`asr.transcribe` now accepts either an already-uploaded `fileId` (preferred — the
server streams bytes from storage via FileModel + FileService.getFileByteArray,
ownership enforced by the userId-scoped model) or inline base64, validated as
mutually exclusive. Avoids inlining large audio through tRPC entirely.
Keep base64 (not a raw Buffer) for the inline path: the tRPC httpLink uses
superjson/JSON, which has no binary representation for Buffer/Uint8Array — a raw
buffer would serialize to a per-byte JSON object, worse than base64.
- add unit tests: base64 path, fileId→S3 download, XOR validation, NOT_FOUND,
NoSuchKey handling
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(server): cap inline base64 audio in asr.transcribe, guide large files to fileId
Inline base64 is only for short clips: the whole tRPC request must fit inside the
platform body limit (~4.5MB on serverless) and base64 inflates bytes by ~4/3, so
cap the decoded audio at 3MB. Oversized payloads are rejected (validated on the
base64 string length, before decoding) with a message pointing to `fileId`.
- add unit test for the oversized-inline rejection
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(cli): upload large audio for `generate asr` instead of inlining base64
Audio over 3MB (matching the server inline cap) is now uploaded via the shared
uploadLocalFile flow and transcribed by `fileId`, so large files never travel
inline through tRPC; smaller clips still go inline as base64. URLs are buffered
to a temp file first so they reuse the same path, and the upload progress note is
written to stderr to keep the transcript on stdout clean.
- add common audio mime types (m4a/wav/ogg/flac/aac/webm) to uploadLocalFile
- test the large-file → fileId path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): pass audio (mp3) attachments to audio-capable models like Gemini
Audio files were classified into the generic `fileList` and only injected as
text via `filesPrompts()`, so Gemini (and any audio-capable model) never
received the actual audio in either server or client runtime. This adds a
first-class audio attachment path mirroring the existing video path:
- types: `ChatAudioItem`, `UIChatMessage.audioList`, `audio_url` content part,
`ModelAbilities.audio`, `VisualFileType` audio
- resolution: classify `audio/*` into `audioList` in the server resolver,
`ingestAttachment`, and the database message model (both query paths)
- context-engine: `processAudioList()` emits `audio_url` parts gated on a new
`isCanUseAudio` capability; `filesPrompts()` / token accounting include audio
- model-runtime: Gemini `buildGooglePart()` handles `audio_url`
(inlineData / external fileData), defaulting mime to audio/mp3
- capability plumbing: `isCanUseAudio` wired through client + server engines
and `RuntimeExecutors`
- model-bank: enable `audio` on multimodal Gemini chat models
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): hand off audio URLs as fileData and estimate audio tokens
Address two review findings on the audio attachment path:
- uriParser: allow audio MIME types (aac/aiff/flac/mp3/ogg/wav) in the Gemini
external-URL whitelist and normalize audio/mpeg|mpg → audio/mp3, so public
audio URLs are handed off as fileData instead of being downloaded and inlined
via imageUrlToBase64 (which can time out / exhaust memory on large files)
- attachmentTokenBuckets: add a dedicated `audioTokens` bucket gated by
`canUseAudio`, mirroring image/video, so audio-capable requests aren't
severely under-estimated at token/cost preflight
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🍱 chore(model-bank): mark audio ability on Vertex AI & AiHubMix Gemini 3 models
The first commit only backfilled `audio: true` on the first-party `google`
provider cards. Mirror the existing `video: true` placement on the other
providers that expose the same Gemini 3 chat models so audio input also works
when routed through Vertex AI (first-party) and AiHubMix.
Image-generation variants (no `video`) and the conservative vision-only proxies
(githubCopilot / opencodeZen / ollamacloud) are intentionally left unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(agent): render uploaded audio attachments in user messages
Audio uploads had no UI render in the chat bubble (unlike images/videos/files).
Add an AudioFileListViewer (native <audio controls> player, mirroring the video
viewer) and wire it into the user MessageContent so uploaded audio shows an
inline player.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(agent): show audio ability badge and allow audio upload for capable models
Two gaps surfaced after enabling the `audio` ability:
- The model picker rendered no audio capability icon (list rows + detail panel),
even though model-bank correctly carries `audio: true` and audio pricing. Add
an audio badge (AudioLines icon) to ModelSelect FeatureTags and the
ModelDetailPanel ability config, with i18n labels.
- Conversation-mode upload hard-blocked all audio files. Allow audio in the
upload guard (mirroring video, with an extension fallback for .m4a etc.) and
gate it by a new `canUploadAudio` ability (isModelSupportAudio selector +
useModelSupportAudio hook) across the Upload / Plus action bars and the
drag-upload zone, so audio-capable models (e.g. Gemini 3) accept audio.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🔧 chore(memory-user-memory): expose prompts/providers/converters subpath exports
The package only exported `.`, `./schemas` and `./types`, so consumers importing
`@lobechat/memory-user-memory/prompts`, `/providers` or `/converters/*` (e.g. the
agent-eval memory scenarios) failed to resolve under strict node_modules. Add the
matching subpath export entries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Update package.json
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): centralize inbox agent meta fallback across read paths
Extract inbox title/avatar fallback into a shared `inboxAgent` util and
apply it consistently wherever agents are read (models, repositories,
openapi service, topic router) so the inbox agent always resolves to its
default title and avatar when the DB row is blank.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): push agent reads into the model layer instead of router-level fallback
Routers/services were querying the `agents` table directly and re-applying the
inbox meta fallback on top — a layering leak. Move those reads down so the
normalization happens once, where the data is owned:
- topic.recentTopics: use `AgentModel.getAgentAvatarsByIds` +
`ChatGroupModel.getMemberAvatarsByGroupIds` instead of inline `.from(agents)`
- add `ChatGroupModel.getMemberAvatarsByGroupIds`, dedupe the duplicated
member-avatar query that also lived in `HomeRepository`
- add `AgentModel.listMessengerBindableAgents` (virtual-or-inbox filter,
`updatedAt DESC`, inbox pinned + normalized) and use it from both
`messenger.listAgentsForBinding` and `MessengerRouter.fetchUserAgents`,
unifying the inbox title fallback on `DEFAULT_INBOX_TITLE` (was a stray
hardcoded `'LobeAI'`)
The `inboxAgent` util is now confined to `packages/database`; no router/service
re-patches inbox meta anymore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): construct agent models via ctx instead of in-handler
Follow the router convention of building models in the procedure ctx
middleware rather than ad-hoc inside handlers:
- topic: move `agentModel` / `chatGroupModel` into `topicProcedure` ctx
(ambient `wsId` scope)
- messenger: the bindable-agents scope is input-driven (the scope picker
passes the workspace), and the procedure is identity-scoped, so expose a
workspace-parameterized `getAgentModel` factory on ctx and call it in
`listAgentsForBinding`
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): drop dead inbox normalization from openapi agent service
The base commit's blanket sweep added `normalizeInboxAgentMeta` to the
openapi `AgentService`, but it doesn't belong there:
- `queryAgents` filters `virtual=false OR NULL` with no `OR slug=inbox`
escape hatch, and the inbox agent is `virtual=true`, so it's excluded by
design — the normalize never sees an inbox row (dead code).
- `updateAgent` only normalizes when updating the inbox via the public REST
CRUD, which shouldn't manage that virtual/system agent.
Remove both calls (restoring pre-branch behavior) and the util import; this
surface lists/edits real agents only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): own messenger bindable-agent title fallback in the model
- return `isInbox` from `AgentModel.listMessengerBindableAgents` so callers no
longer recompute it from `slug`
- move the blank-title fallback into the model via an optional `fallbackTitle`
option; `MessengerRouter.fetchUserAgents` passes "Custom Agent" (text-only
channel) while `messenger.listAgentsForBinding` omits it and keeps null so the
web picker applies its own i18n default
- `messenger.listAgentsForBinding` now returns the model rows directly
- revert openapi `agent.service.ts` to canary: the inbox is virtual and excluded
from that surface, so its earlier normalization was dead code
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(agent-documents): present created document as a clickable link, not a raw id
When createDocument succeeded, the agent surfaced the internal document id to the
user instead of the clickable URL. Two causes: the tool system prompt explicitly
told the model to "Include key identifiers (document ID/title)", and the tool
result string buried the URL in parentheses while emphasizing "Use id <id> for
further edits". The raw id is not actionable for users and didn't even match the
URL path segment.
- systemRole: instruct presenting the document URL as a markdown link and never
exposing the internal id to the user
- ExecutionRuntime: reword the create result so the URL is unambiguously the
user-facing link and the id is clearly internal-only
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent-documents): centralize result strings in @lobechat/prompts and surface url everywhere
Two follow-ups to the create-document link fix:
1. Every document-referencing tool result now carries the shareable URL, not
just createDocument. replace/modify/rename/copy/updateLoadRule build the url
via a shared buildDocumentUrl helper, and listDocuments includes a per-item
url so the agent can link any existing document. removeDocument stays id-only
(the document is gone); readDocument returns raw content untouched.
2. The result content strings are no longer hand-written at each return site.
They live in @lobechat/prompts (prompts/agentDocuments/formatResults), mirroring
the existing fileSystem formatters, with a single link/id policy: when a url
exists the agent relays a clickable markdown link and keeps the internal id to
itself; otherwise the id is shown as the only handle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(agent-documents): assert full result strings with toBe instead of toContain
Exact-equality assertions make the formatter output verifiable at a glance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Publishing to the community is approval-gated server-side — manual
publish/unpublish from the client was rejected with `forbidden`, so the
buttons, modals and supporting code were effectively dead. Remove the
client-side publish flow for agents and groups, keeping only `deprecate`.
- Drop the publish menu item + modals from the agent profile header
(ForkConfirmModal, PublishResultModal, PublishConfirmModal, useMarketPublish)
- Remove GroupPublishButton and its modals from the group profile
- Drop the publish/unpublish actions from the community user agent/group
cards, keeping the deprecate action
- Remove useCommunityPublishGuard / useAgentOwnershipCheck hooks and the
canCurrentAgentPublishToCommunity selector
- Drop the 'publish' MarketAuth scene and publish/submit auth copy
- Remove MarketApiService publish/unpublish methods, the stale
setAccessToken no-op, and MARKET_ENDPOINTS
- Remove the now-unused marketPublish / submit / publish i18n keys
#15841 extracted the snapshot store creation behind a `loadModule(moduleName)`
indirection. The bundler can't statically analyze the indirected `require()`, so
the `@/server/modules/AgentTracing` build-time alias fails to resolve at runtime
(`MODULE_NOT_FOUND`), the `catch {}` swallows it, and `createDefaultSnapshotStore`
returns `null`. Since v2.2.5 this silently disabled ALL production agent trace
snapshots: `CompletionLifecycle` still writes `trace_s3_key` to the DB, but no
partial/final object is ever uploaded to S3 — `agent-tracing inspect` 404s on
every operation.
- statically import `S3SnapshotStore` / `FileSnapshotStore` and `new` them
- keep testability via injected constructor factories, not a module loader
- surface S3 construction failures with `console.error` instead of a silent
`catch {}` (best-effort: still degrades to null, never breaks the run)
- test the real (non-injected) default path so the regression can't return
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): stop chat mode from auto-injecting local-system tools
Chat mode and the device track are orthogonal: the Chat/Agent UI toggle
only writes `chatConfig.enableAgentMode` and never touches
`agencyConfig.executionTarget`. A default/stored `local` target therefore
still resolved an `activeDeviceId`, and `buildStepToolDelta` re-injects
`LocalSystemManifest` whenever an active device is set — bypassing the
rule-layer whitelist that chat mode relies on.
Resolve the device decision with chat mode in mind: `resolveExecutionPlan`
now accepts `isChatMode` and degrades the whole plan to `none` (target
included, so the `target === 'local'` manifest-injection path is closed
too). The client `resolveLocalDeviceId` also skips the deviceId round-trip
in chat mode; the server remains authoritative since it auto-activates a
single online device regardless of what the client sends.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent): centralize tool-mode derivation in resolveToolMode
Move the chat-mode judgment inside resolveExecutionPlan instead of
computing isChatMode at the call site. Add a shared resolveToolMode helper
(toolMode wins; else enableAgentMode) used by resolveExecutionPlan, the
server tools engine, and the client chatConfig selector — one source of
truth for what counts as chat mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(agent): mock isChatModeById in gateway action tests
resolveLocalDeviceId now calls chatConfigByIdSelectors.isChatModeById, but
the gateway test mock only stubbed getChatConfigById / isLocalSystemEnabledById,
so the desktop local-device cases threw "isChatModeById is not a function".
Add the selector to the mock (default false) plus a chat-mode regression
case asserting no deviceId is resolved on a local target in chat mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(topic): correct "Copy Session ID" label to "Copy Topic ID"
The topic item dropdown action copies the topic id
(navigator.clipboard.writeText(id) — the same id the surrounding menu
passes to openTopicInNewWindow and SESSION_CHAT_TOPIC_URL), but it was
labeled "Copy Session ID" / "Session ID copied". Correct the English
source strings to "Copy Topic ID" / "Topic ID copied".
English source locale only; the i18n key actions.copySessionId is kept,
and the other locales regenerate from en-US.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(i18n): correct legacy "session" wording in English UI strings
Follow-up to #15942. Several English strings used "session" where the
code shows the referent is the live conversation, the agent, or where the
word is redundant. "session" is also legitimate elsewhere (auth/sign-in
session, AWS Session Token, Claude Code runtime session, natural English),
and many sessionGroup/defaultSession keys already have correct values, so
only code-verified mislabels are changed:
- chat: "Clear current session messages" / "…clear the current session
messages…" / "Save current session as topic" → "conversation"
(the Clear and Save-Topic actions operate on the current conversation)
- setting: "All session messages have been cleared" → "conversation";
"…display in the session." → "…in conversations."
- setting: "Session Settings" (per-agent page, sibling of "Group
Settings") → "Agent Settings" / "· {{name}}" / "Agent Profile and chat
preferences"
- setting: workspace-delete warnings drop the redundant legacy "sessions"
(agents have their own bucket; topics already listed)
English source locale only; i18n keys unchanged, other locales regenerate
from en-US.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(i18n): align "Discover Assistants" nav with "Agent" terminology
The desktop nav read "Discover Assistants" while the entire discover
feature uses "Agent" (Community Agents, Featured Agents, Add Agent,
Agent List…). Rename the lone outlier to "Discover Agents".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(cmdk): merge Image/AI Video into one "Generation" entry
The command palette's Navigate group listed separate "Image" (/image)
and "AI Video" (/video) entries, but the app sidebar combines both into
a single "Generation" item (useNavLayout's bottomMenuItems →
tab.generation → /image). Mirror that in getNavigableRoutes — its only
consumer is the CMDK navigate list — by dropping the separate video
entry and relabeling image to "Generation", merging both keyword sets so
image and video terms still match. Reuses the existing tab.generation key
so the label stays in sync with the sidebar across every locale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(i18n): apply terminology fixes to the default .ts source of truth
The earlier commits changed only locales/en-US/*.json, but those files are
generated from packages/locales/src/default/*.ts via the workflow:i18n step
(.i18nrc.js entry = locales/en-US), so a regeneration would revert them.
Apply the same English-source edits to the .ts defaults so the source of
truth and the en-US mirror agree:
- topic: copySessionId / copySessionIdSuccess → "Copy Topic ID" / "Topic ID copied"
- chat: clearCurrentMessages / confirmClearCurrentMessages / topic.saveCurrentMessages → "conversation"
- setting: danger.clear.success / llm.modelList.desc → "conversation(s)";
header.session(+WithName/Desc) → "Agent Settings" / "chat preferences";
workspace-delete strings drop the redundant legacy "sessions"
- electron: navigation.discoverAssistants → "Discover Agents"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(i18n): unify stray terminology (Focus Mode / Agent / Skill / Library)
Continue the English copy-consistency cleanup, aligning straggler strings
to the terms the app already standardizes on (zh-CN already uses these):
- zenMode label "Zen Mode" → "Focus Mode" (the toast; settings/hotkey
already say Focus Mode, and zh-CN is 专注模式 everywhere)
- "assistant" → "agent": agent channel descriptions (×9), topic move
actions (×4), labs agent self-iteration
- "Plugin" → "Skill": lobe-agent-management install labels, discover
user.plugins / user.noPlugins
- "knowledge base" → "Library": file library-import strings, chat agent
profile counts + search-library tool label
- "service provider" → "provider": new-provider id description
English source (.ts) + en-US mirror; canonical terms already match zh-CN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(i18n): apply confirmed naming decisions (Orchestrator / Group / Clear)
Resolve the remaining English terminology forks per product decision:
- group coordinator role → "Orchestrator" (was Supervisor / Group Host /
"host" / "moderator"): chat profile + token labels; setting group-chat
system-prompt strings and enable-orchestrator description
- cmdk search → "Group" / "Groups" (was "Agent Team(s)")
- data-wipe titles → "Clear Data" / "Clear Workspace Data" (was "Wipe…")
- model ability → "Tool Calling" / "tool calls" (was "Tool Usage" /
"function calls")
- topic import → "Import Topics" / "Importing topics…" (match Export Topics)
English source (.ts) + en-US mirror.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(i18n): standardize auth on "Sign in" + unify conversation wording
Per product decision (match the /signin route path):
- auth / authError / oauth / error / marketAuth / desktop-onboarding:
"Log in" / "Login" / "Log Out" → "Sign in" / "Sign-in" / "Sign Out"
across the auth flow (button, errors, guide titles, OAuth consent,
unlock screen, session-expiry prompts)
- common "SSO Login" → "SSO Sign-in"; discover user menu "Logout" → "Sign out"
- memory analysis modal: unify "chats" / "topics" → "conversations"
English source (.ts) + en-US mirror (regenerated via workflow:i18n).
External-tool logins (GitHub CLI, WeChat) intentionally kept; non-English
locales left to the i18n CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔥 chore(zen-mode): remove the Zen/Focus Mode feature
Zen Mode (hide all chrome to show only the current conversation) is no
longer needed. Remove it end to end:
- delete src/features/ZenModeToast
- drop status.zenMode state, the toggleZenMode action, and the inZenMode /
showChatHeader selectors (the chat header now always renders)
- remove the ToggleZenMode hotkey (chat-scope hook, const registration,
HotkeyId type)
- un-mask the panel-visibility selectors (drop the !zenMode guard)
- remove the zenMode / toggleZenMode locale keys (chat, hotkey)
- update the affected store/hook tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: add translation
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(model-runtime): strip null/empty members from enum for Gemini
The memory tool's `updateIdentityMemory.set.memoryType` enum carried a
trailing `null` sentinel (`[...MEMORY_TYPES, null]`). Gemini's schema proto
only accepts STRING enum members, so the `null` was coerced to `""` and
rejected with `enum[10]: cannot be empty`. OpenAI/Anthropic don't validate
enum members, so it only broke on Gemini.
Two layers:
- Source: drop the `null` sentinel from the memoryType enum; nullability is
already expressed via `type: ['string', 'null']`.
- Conversion fallback: sanitizeGeminiSchema now filters null/non-string/empty
enum members (and strips the enum entirely if none remain), so any other
tool that does the same won't break Gemini.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✅ test(model-runtime): align Gemini enum tests with null-member stripping
The sanitizeGeminiSchema fix now filters null/non-string members out of enums,
so the two "preserve null enum" assertions no longer match. Update them to
expect the null sentinel stripped while nullability stays in type: ['string', 'null'].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
File routes (preview / move / rename / write) received `workingDirectory`
from the same untrusted browser session that supplies the file paths. The
gateway's containment check only proves paths sit inside that directory, not
that the directory itself is legitimate — so `workingDirectory: "/"` passed
trivially and reached any path on the device.
Re-derive approved roots from the server-owned device row (`workingDirs` +
`defaultCwd`) and require the requested root to equal or nest inside one. The
check is built into a shared `workspaceFileProcedure` so every file-mutating
route inherits it and can never forget it.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The changelog modal renders each entry's full body, so the long
"Improvements" and "Fixes" sections take a lot of vertical space. A remark
plugin now wraps those two standardized headings (and their content) in a
collapsible section that renders collapsed by default; "Features" stays open.
- remarkCollapsibleSections: emits a <collapsible-section> element (via
data.hName) for exact "Improvements"/"Fixes" headings — the react-markdown
equivalent of an MDX component
- CollapsibleSection: @lobehub/ui Collapse, collapsed by default
- CustomMDX: accept and merge caller-supplied remarkPlugins
- ChangelogContent: opt in to the plugin + map the custom element
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🔖 chore(cli): bump @lobehub/cli to 0.0.32
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* update
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardcode "Agent Gateway" as the menu toggle label (brand name, identical
in every language, so no i18n). The info popover keeps a dedicated
cardTitle ("Agent Gateway Mode" / "Agent Gateway 模式").
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(cli): support uploading local files via `file upload` and shared helper
Add `lh file upload [source]` support for local paths (positional or `-f/--file`)
alongside the existing URL mode, and extract the local-upload logic shared with
`kb upload` into a reusable `uploadLocalFile` helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ⚡️ perf(cli): skip S3 upload when local file hash already exists
`uploadLocalFile` now calls `file.checkFileHash` before the S3 PUT and, when
the same bytes are already stored (and the object still exists), reuses the
existing url instead of re-uploading. Mirrors the dedup short-circuit of the
URL upload mode. Benefits both `file upload` and `kb upload`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
OpenRouter's built-in web search (and other OpenAI-compatible providers) may
emit empty citation objects like `{}`. These propagated unfiltered through the
OpenAI grounding stream branches, then crashed both the renderer
(`new URL(undefined)` in SearchGrounding) and message persistence (Zod requires
`url` to be a string in GroundingSearchSchema).
- model-runtime: add a shared `filterValidCitations` helper and apply it to all
OpenAI grounding branches (url_citation / messages / xAI / XiaomiMiMo), plus
reuse it for the existing Perplexity/Zhipu filter. Guard `url_citation?.*` too.
- SearchGrounding: filter out citations without a url and parse the favicon host
defensively so a malformed entry can never crash the render.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
# 🚀 LobeHub Release (20260617)
**Release Date:** June 17, 2026
**Since v2.2.5:** 42 commits · 42 merged PRs · 8 contributors
> This weekly release deepens server-side agent orchestration, brings
desktop file and worktree capabilities to the web through device RPC,
and smooths out the everyday rough edges — cold-start boot, connector
credential safety, and chat refresh feedback.
---
## ✨ Highlights
- **Server-side Group Orchestration** — Agents can now call other agent
members server-side, enabling multi-agent collaboration without a
desktop in the loop. (#15870)
- **Desktop File Ops on the Web** — Project file operations and `git
worktree` listing now replicate from desktop to web via device RPC, so
cloud sessions can read and act on local working directories. (#15885,
#15889)
- **Fleet Running-Tasks Dashboard** — A lab-gated dashboard for
in-flight Fleet tasks, with running topics re-synced each time the
Observation tab opens. (#15817, #15922)
- **callAgent as a Deferred Tool** — The agent loop now runs `callAgent`
as a deferred tool, giving cleaner sub-agent invocation and tool-chain
handling. (#15765)
- **Connector Credential Safety** — Editing a connector no longer risks
silently wiping saved credentials; they are restored in edit mode and
preserved on save. (#15909)
- **Smoother Cold Start** — Boot now shows one continuous loading screen
instead of a brand-logo flash on cold start. (#15926)
---
## 🏗️ Core Agent & Architecture
- Improved connector, document, and Fleet agent workflows. (#15936)
- Scoped the agent conversation subtree to an explicit `agentId` for
clearer multi-agent boundaries. (#15866)
- Added a role-aware dual-form message-chain reader to the conversation
flow. (#15908)
- Anchored the server-side main chain to a run's real last tool in
heterogeneous agents. (#15883)
- Drove resume completion off the authoritative Durable Object status in
the gateway client. (#15919)
- Corrected target `agentId` and refreshed the sidebar in gateway mode
for the agent builder. (#15888)
- Forwarded model extend params on the server-side agent runtime.
(#15891)
- Preserved preference-memory receipt routing in agent signals. (#15892)
- Filtered the `.tool-results` archive out of document lists by default.
(#15935)
- Optimized the agent document list query. (#15904)
---
## 📱 Devices & Platforms
- Locked a run to the explicitly selected device, never offering
device-switching mid-run. (#15914)
- Exposed `deviceRouter` on the mobile router. (#15925)
- Opened a new Home tab from the desktop tab bar "+" button. (#15825)
- Added support for approved external local file previews on desktop.
(#15895)
- Removed web onboarding aliases from the desktop build. (#15902)
- Consolidated auth SPA loading. (#15903)
---
## 🖥️ User Experience
- Fixed assorted workspace problems and clarified workspace copy/move
actions. (#15928, #15897)
- Showed a cached message-refresh hint with breathing room around it.
(#15901, #15906)
- Added an unread-reply indicator on collapsed project groups. (#15915)
- Anchored the sidebar spacer immediately after the accordion block.
(#15871)
- Capped nested thread-list height with scroll overflow. (#15861)
- Removed the ParamsPanelToggle control icon from the chat header.
(#15860)
- Defaulted the React Scan scanner UI to off. (#15934)
- Refined top-up best-value and referral reward-rules copy. (#15924,
#15923)
- Only enforced the chat upload file-type whitelist in chat mode.
(#15884)
---
## 🔒 Reliability
- Deduped unread-count polling to reduce redundant requests. (#15881)
- Aligned dayjs locale imports. (#15896)
---
## 👥 Contributors
Huge thanks to the **8 contributors** who shipped **42 merged PRs**
across **42 commits** this cycle.
@arvinxx · @Innei · @tjx666 · @LiJian · @AmAzing129 · @sudongyuer ·
@rivertwilight · @Rdmclin2
---
**Full Changelog**:
https://github.com/lobehub/lobehub/compare/v2.2.5...release/weekly-20260617
* ✨ feat(agent): allow removing unauthorized connectors from the auth alert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 💄 style(chat): add Beta tag and info popover to Gateway Mode toggle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 💄 style(fleet): render OpStatusTray seamlessly when no reply panel is attached
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(fleet): show skeleton rows while the running-task sidebar loads
The sidebar fell straight through to the "no running tasks" empty state
during the initial fetch, so a brief flash of "empty" hid tasks that were
actually loading. Thread the SWR loading flag in and render placeholder
rows until the first result lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(fleet): open existing agents from the board picker & fix reply-tray chrome
- AddColumnButton: selecting an agent now opens its main conversation
(topicId null) instead of minting a throwaway empty topic via an async
createTopic that could silently fail; dedupes + scrolls to an already-open
column. Matches "open this agent" elsewhere in the app.
- AgentColumn: stop double-rendering OpStatusTray while the reply panel is open
(ChatInput owns its own overlay tray); lift the collapse button above that
floating tray so it no longer cuts the tray's top border; give ChatList its
own flex region so the seamless tray isn't squeezed/clipped.
- OpStatusTray: keep a hairline top divider in seamless mode so the running tray
still reads as separated from the conversation above.
- RunningTaskSidebar / AddColumnButton: harden scroll-into-view with double rAF
so the (re-)added column reliably scrolls into view after it commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(fleet): add one-click close-all-idle-columns action
Adds a `removeColumns` batch store action and wires the running-board
header button (committed earlier in e47228c6f7) to it, so users can clear
every non-running column in one click. Idle is derived from the board's
own columns against the live running set; running columns are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ add agent document share URLs
* ✨ add standalone agent document page
* ✨ open agent documents as pages
* 💄 style(chat): polish Agent Gateway mode popover
* 🐛 fix(fleet): stabilize reply area and panel collapse state
* 🐛 fix: restore agent document portal opening
* 💄 style: adjust agent document header actions
* 🐛 fix: handle workspace document links and fleet idle state
* 🐛 fix(portal): import WideScreenContainer in document Body
The full-page document view rendered <WideScreenContainer> without
importing it, breaking type-check (TS2304: Cannot find name).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(agent-documents): filter `.tool-results` archive from document lists by default
The auto-created `.tool-results` archive folder leaked into the user-facing
documents panel because `listDocuments` / `listDocumentsForTopic` did not apply
the `excludeArchivedToolResults` filter that other read paths already use.
Make the service filter the archive by default, and let the agent
document-listing tool (server + client runtimes) explicitly opt back in via
`includeArchivedToolResults`, preserving agent archive discovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-documents): hide archived tool result in current-topic lists
The `.tool-results` archive folder is created by mkdir but only the archived
file is associated with the topic (see archiveToolResultIfNeeded), so the
folder row never appears in the current-topic list. `excludeArchivedToolResults`
derived the archive folder id from the list alone, leaving the set empty and
leaking the archived `.txt` into `scope: 'currentTopic'` results.
Look the `.tool-results` root folder up directly in listDocumentsForTopic and
pass its id into the filter via the new explicit `archiveFolderIds` argument.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(agent-documents): assert runtime opts into archived tool results
The server runtime now calls listDocuments with
`{ includeArchivedToolResults: true }`; update the expectation accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(project-file): replicate desktop file operations to web via device RPC
Project file tree operations only ran over Electron IPC, so remote/web
devices could browse files but not move, rename, or edit them. This wires
move/rename/write through the device RPC the same way getProjectFileIndex
already does, reusing the host-agnostic @lobechat/local-file-shell impls.
- device-control: whitelist moveLocalFiles/renameLocalFile/writeLocalFile + dispatch cases
- deviceGateway: moveProjectFiles/renameProjectFile/writeProjectFile (mutations throw on failure, no silent degrade)
- device router: matching device.* procedures
- projectFileService: deviceId-aware chokepoint methods (IPC locally vs RPC remotely)
- saveLocalFile now routes through projectFileService; remote LocalFile editor is no longer read-only
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(provider): rename ClientMode to CustomProviderDetail
ClientMode is a leftover from the old client-mode (IndexedDB) vs
server-mode (Postgres) DB split; there is no ServerMode counterpart and
the name no longer reflects what the component does — it renders the
detail view for a custom (user-created) provider, fetched by id. Rename
it to CustomProviderDetail and update the debugId + import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔒 fix(project-file): contain remote file mutations to the workspace root
Guard the move/rename/write device RPCs against paths escaping the project
root: these routes accept absolute paths from an untrusted browser session,
so the gateway now confirms every path stays within the working directory
(Windows-aware) before forwarding to a device. Thread `workingDirectory`
through the service and tRPC layers.
Also scope edit buffers by tab identity (device + working directory + path)
instead of bare file path, so the same path opened on two devices/workspaces
keeps independent unsaved content, and surface write failures so a failed
save keeps the buffer dirty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
🐛 fix(boot): keep one continuous loading screen instead of flashing the brand logo on cold start
CacheHydrationGate held the routed app behind its own full-screen
ProductLogo while the SWR IndexedDB cache tier hydrated. Because it only
gated once auth resolved, the boot painted the app shell first, then
flipped to the logo when auth switched the scope anon→user (triggering a
cache reload), then back to the app — an app→logo→app flicker.
Now the gate renders nothing while booting and keeps the static HTML
#loading-screen visible, then removes it in the same layout pass that
mounts the children — one continuous loader → app hand-off, no second
in-React logo. It also gates through the pre-auth phase so the scope flip
no longer causes a mid-boot flash. SPAGlobalProvider no longer removes
#loading-screen on mount; that is now owned by the gate.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Mounts the existing `deviceRouter` (from lambda) on the mobile tRPC root
so the mobile app can call `device.listDevices` (and other device RPCs)
to drive the chat input's execution-target picker — aligning mobile
device handling with the web `HeteroDeviceSwitcher` UX.
`deviceProcedure` only uses `authedProcedure + serverDatabase`, both of
which the mobile route already provides via `createLambdaContext`, so no
context changes are needed.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 🐛 fix(fleet): re-sync running topics each time the Observation tab opens
The board seeded its columns through a `seeded` flag on the session-singleton
store, so the seed effect only ran once per app load. Desktop tabs are in-SPA
navigations that remount FleetView, but the flag stayed true — so re-opening
the tab showed a column set frozen from its first open, and topics that started
running afterward never appeared.
Replace the once-per-load seed with a per-mount sync: on each open, add a
column for every currently-running topic via the idempotent addColumn, tracking
already-synced keys so newly-running topics still pop in, manually-closed
columns stay closed, and manual/reordered columns are preserved. Remove the now
dead seedColumns/seeded from the store.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(conversation): init agent config in ChatList so author titles resolve
ChatList now self-inits its conversation's agent config into the agent
store, so message author titles resolve via useAgentMeta instead of
falling back to "未命名助理". Secondary mounts (each Fleet column, the
share page) never went through the route-level init that populates
agentMap. Idempotent via SWR key dedup; gated on isLogin.
Also gate the Fleet column reply bar on messages being loaded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(fleet): move create-task to a pinned footer, count as a tag, rename to Running Board
- Move the "create task" entry out of the cramped header into a full-width
button pinned at the bottom of the sidebar (added an optional `footer` slot
to SideBarLayout) — it was easy to miss at the top-right.
- Show the open-column count as a Tag beside the title, hidden when zero.
- Rename the sidebar title from "Running Tasks" to "Running Board" (运行看板).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs(fleet): update sidebar doc comment for footer create-task
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(fleet): move create-task to the top of the sidebar body
Place the "create task" button at the top of the running-board list instead of
a pinned footer, and drop the now-unused footer slot from SideBarLayout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(fleet): shrink create-task button to default sidebar size
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(fleet): persist board columns, refetch on focus, polish drag UI
Make the running board a live, durable overview:
- Refetch the running-topic set on focus near-instantly (focusThrottleInterval
1s) so newly-running topics appear the moment the user looks at the board.
- Persist the column set (manual pins + kept running topics) and per-column
closures to localStorage, replacing the once-per-mount syncedKeys seed with a
syncRunningColumns reconciliation that only appends: new running topics pop
in, manual pins and ordering stay put, and a column closed while still
running won't immediately re-add (dismissal clears once it stops).
- Columns now stay until the user closes them; a topic that drops out of the
running set reads as "idle" (StatusDot accepts an absent status).
- Round the dragged column's corners (8px) and clip its contents.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(fleet): move running-topics SWR key into the central registry
Replace the inline literal/local const with a dedicated `fleet` domain in the
SWR key registry (`fleetKeys.runningTopics()` → ['fleet:runningTopics']), so the
board's cache key follows the `<domain>:<resource>` convention and the tiered
cache provider / matchDomain('fleet:') treat it as its own namespace.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(fleet): turn collapse-reply control into a labeled text button
Replace the bare collapse ActionIcon with a centered text Button carrying the
"Collapse" label so the reply-collapse affordance reads clearly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(fleet): live topic status, collapsible reply, row layout & pin controls
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(fleet): use running-op start time for sidebar elapsed clock
The sidebar running-time readout anchored on topic.createdAt, so it counted
from topic creation (hours off) instead of the current run. Switch to the same
baseline the sidebar topic row uses — operationSelectors
.getAgentRuntimeStartTimeByContext, the running operation's metadata.startTime —
falling back to the StatusDot label when no running op is loaded.
Also widen the OpenAI Responses `create` spy assignment (overloaded signature
isn't assignable to the generic MockInstance fallback under tsgo).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-gateway-client): drive resume completion off authoritative DO status
A fresh subscriber (no lastEventId) on a hibernated DO replays zero events.
The client used to guess "completed" from a 3s silence and emit session_complete,
which cleared the shared topic.metadata.runningOperation and cancelled the run
on every device — opening a topic on a 2nd device killed the 1st device's run
(LOBE-10443).
Consume the new `resume_complete` message (the DO's stored status, which
survives hibernation) as ground truth: still running / waiting → stay connected
and keep streaming; terminal → complete. The destructive 3s empty-replay
timeout is removed entirely — completion is never guessed from silence. If
`resume_complete` never arrives (e.g. a rolled-back DO), the client just waits,
a safe and recoverable state, with heartbeat loss still forcing reconnect.
Requires agent-gateway#8 (DO sends resume_complete), which deploys first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-gateway-client): opt into resume_complete via wantStatus flag
Set `wantStatus: true` on the resume message so the (revised, non-destructive)
gateway hands back the authoritative session status only to clients that
understand it. A legacy gateway ignores the flag and replays only; this client
then relies on live events and never guesses completion from silence.
Pairs with agent-gateway forward-fix (opt-in gating, no synthesized
session_complete). Safe against both old and new gateways, in either deploy
order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(topic): show unread-reply indicator on collapsed project groups
When a project topic group is collapsed, surface an aggregated unread
indicator (animated ripple dot) if any child topic has an unread
completed generation, so users notice replies without expanding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(device): stabilize device ordering with createdAt tie-break
`lastSeenAt` is written from a JS `new Date()` (ms precision), so two
rapid registers can tie on it and leave ordering undefined. Break ties
by `createdAt` (DB-side now(), µs precision) for stable ordering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 💄 style(tool-ui): drop redundant Request section from Linear render
The Inspector already surfaces tool inputs, so rendering request args
again in the Linear result view is redundant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): delegate callAgent via server runner
* ♻️ refactor(agent): run callAgent as deferred tool
* ✅ test(agent): cover server callAgent deferred flow
* ♻️ refactor(conversation-flow): role-aware dual-form message-chain reader
Make the read side role-aware so both persisted chain shapes parse to
equivalent display output (LOBE-10445 phase 1):
- tool-anchored (legacy): next step's assistant hangs off the previous
step's last tool result
- assistant-anchored (new): next step's assistant hangs off the most
recent non-tool message, so a tool result and the next assistant are
siblings under one assistant
Two invariants drive a single reader: a `tool` message is always inline
data of its assistant; a branch is >=2 non-tool siblings under one parent.
The continuation walk now looks for the next spine assistant among the
assistant's own non-tool children as well as its tools' children;
group detection keys on "has >=1 tool child"; branch detection counts
non-tool children only.
Pure read-side, no write-path change — ships independently. Verified
against 5 fixture classes (old / new / mixed / parallel-tool /
regenerate-branch) asserting flatList + contextTree parity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(conversation-flow): guard assistant-anchored chain continuation
Address two edge cases in the dual-form continuation finder where seeding
the candidate set with the assistant's own id (the new-form path) bypassed
logic the per-tool path already had:
1. Regenerated continuation: a tool-using assistant can have two non-tool
assistant children beside its tool result. The finder flattened all
candidates and returned the earliest, ignoring the parent's
activeBranchIndex and dropping the other branch. Route >1 non-tool-child
sets through BranchResolver before picking a linear continuation.
2. Async-task summary: when a tool spawned tasks but the follow-up summary
uses the assistant-anchored parent (summary.parentId === assistant.id),
the assistant seed bypassed the task/AgentCouncil fan-out guard and the
summary got folded into the AssistantGroup before the tasks aggregation.
Apply the same fan-out guard to the assistant-anchored candidate so the
group -> tasks -> summary order is preserved.
Both the flat (findFlatChainContinuation) and tree (findChainContinuationNode)
variants share a resolveActiveContinuationId helper; BranchResolver is now
injected into MessageCollector. Adds two fixtures (⑥ regenerated branch,
⑦ async-task summary). conversation-flow: 143 passed, type-check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
When a device is explicitly selected (`boundDeviceId`), the run must stay on
it and the model must never be able to activate / switch to another machine.
The remote-device (activate-device) tool was gated only by `!autoActivated`.
That left a hole: when the selected device went OFFLINE the plan became
`device-unrouted`, `autoActivated` flipped to false, and the activate-device
tool resurfaced — letting the model silently hop onto a *different* online
device. That is exactly the "auto-replace to another device after offline"
behavior we want gone.
Also suppress the tool whenever `boundDeviceId` is set, regardless of online
status. An explicitly selected device now locks the run: the tool is never
offered, so the run stays unrouted until that device comes back instead of
switching machines. The unbound case is unchanged — the tool is still offered
so the model/user can pick a device.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
chore: remove LOBE-XXX markers from code comments
Replace LOBE-XXX ticket references in code comments with descriptive
context from the corresponding Linear issues. The markers served as
internal tracking anchors during development but are inappropriate
for the open-source codebase.
Files changed:
- AgentRuntimeService.ts: LOBE-10385 → async sub-agent suspend/resume
stability hardening context
- observability-otel/agent-runtime/index.ts: same LOBE-10385 context
- buildRunLifecycle.ts: LOBE-10378/10379/10382 → run lifecycle and
transport unification context
- streamingExecutor.ts: LOBE-10378 reference removed
- modelExtendParams.test.ts: LOBE-10442 → Gemini 3 Pro reasoning token
context
Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
* 📝 docs: add June 15 weekly changelog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 docs: restructure last 12 changelog entries into Features/Improvements/Fixes
Normalize section headings, split improvements from fixes, plainer wording, and fewer em-dashes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: remove Claude Fable 5 from June 15 changelog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🍱 docs: add cover image for June 15 changelog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-builder): correct target agentId and refresh sidebar in gateway mode
In gateway mode, AgentBuilder's tool calls (updateConfig / updatePrompt /
installPlugin) were targeting the builtin builder agent instead of the agent
being edited, and the left-sidebar never refreshed after a successful write.
Two root causes fixed:
1. **Wrong agentId on server** — `executeGatewayAgent` only sent `context.agentId`
(= the AgentBuilder builtin) to the server. The editing target was only held
in `chatStore.activeAgentId` (synced by AgentBuilderProvider) but never
forwarded. Now, when `scope === 'agent_builder'`, the client sends
`appContext.editingAgentId = chatStore.activeAgentId`. The tRPC Zod schema
and `ExecAgentAppContext` type both accept the new field, and
`aiAgent/index.ts` uses it to override the operation's `agentId` so
`state.metadata.agentId` (and therefore `ctx.agentId` in the server
executor) points to the correct editing target.
2. **No sidebar refresh** — In client mode the `AgentManagerRuntime` directly
calls `agentStore.optimisticUpdateAgentConfig()`, which triggers a Zustand
re-render. In gateway mode the update happens server-side so no Zustand
mutation ever fires. Fixed by adding an `onAfterCall` hook to
`AgentBuilderExecutor`: after any successful write it reads the editing
agent ID from `chatStore.activeAgentId` and calls
`getAgentStoreState().internal_refreshAgentConfig()` to re-fetch and
re-render the sidebar.
Closes LOBE-10441
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(agent-builder): isolate editingAgentId to avoid message ownership desync
Per code review: the previous fix overrode state.metadata.agentId with the
editing target, but messages are already written with persistAgentId =
resolvedAgentId (the builder builtin). AgentRuntimeService.queryUiMessages
reads metadata.agentId to filter messages, so overriding it would cause the
gateway handler to snapshot the wrong topic and desync the builder conversation.
Correct approach: keep agentId as the builder builtin throughout. Carry
editingAgentId as a separate metadata field that only flows through to
ToolExecutionContext, where the AgentBuilder server runtime reads it via
ctx.editingAgentId ?? ctx.agentId. No other part of the pipeline is affected.
Changes:
- apps/server/src/services/aiAgent/index.ts: revert agentId override; keep
editingAgentId as an independent appContext field (conditional spread)
- apps/server/src/services/toolExecution/types.ts: add editingAgentId to
ToolExecutionContext
- apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts: forward
state.metadata.editingAgentId into the ToolExecutionContext
- apps/server/src/services/toolExecution/serverRuntimes/agentBuilder.ts:
use ctx.editingAgentId ?? ctx.agentId in all three write methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): restore credentials in edit mode, prevent silent wipe on save
Three bugs caused custom connector headers / bearer tokens to be lost silently:
1. Dead-code branch in edit-mode save: `authType === 'header'` could never be
true (the auth radio only has none/bearer/oauth2), so every save with
`authType === 'none'` hit `patch.credentials = null` and wiped whatever
was stored — including valid header credentials. Fixed by mirroring the
create-mode logic: `authType !== 'oauth2'` → check Advanced headers → save
`{type:'header'}` if present, null otherwise.
2. `list` API strips credentials entirely, so `editValue` always computed
`authType = 'none'` and `headers = undefined`, leaving the edit form blank
even when credentials were saved. Added `getForEdit` tRPC query that
returns the decrypted user-set credentials (bearer token, custom headers)
while still excluding machine-managed OAuth tokens and DCR client secrets.
`CustomConnectorModal` now fetches this on open and builds `editValue`
from the real data.
3. `DevModal` seeded the form once on mount (`useEffect([], [])`). Since
credentials are loaded asynchronously after open, the form was already
seeded with empty data before the fetch completed. Changed to a
`seededRef`-guarded effect on `[open, value]`: resets on close, seeds once
when the value arrives, and never overwrites user edits mid-session.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(agent-builder): correct target agentId and refresh sidebar in gateway mode
In gateway mode, AgentBuilder's tool calls (updateConfig / updatePrompt /
installPlugin) were targeting the builtin builder agent instead of the agent
being edited, and the left-sidebar never refreshed after a successful write.
Two root causes fixed:
1. **Wrong agentId on server** — `executeGatewayAgent` only sent `context.agentId`
(= the AgentBuilder builtin) to the server. The editing target was only held
in `chatStore.activeAgentId` (synced by AgentBuilderProvider) but never
forwarded. Now, when `scope === 'agent_builder'`, the client sends
`appContext.editingAgentId = chatStore.activeAgentId`. The tRPC Zod schema
and `ExecAgentAppContext` type both accept the new field, and
`aiAgent/index.ts` uses it to override the operation's `agentId` so
`state.metadata.agentId` (and therefore `ctx.agentId` in the server
executor) points to the correct editing target.
2. **No sidebar refresh** — In client mode the `AgentManagerRuntime` directly
calls `agentStore.optimisticUpdateAgentConfig()`, which triggers a Zustand
re-render. In gateway mode the update happens server-side so no Zustand
mutation ever fires. Fixed by adding an `onAfterCall` hook to
`AgentBuilderExecutor`: after any successful write it reads the editing
agent ID from `chatStore.activeAgentId` and calls
`getAgentStoreState().internal_refreshAgentConfig()` to re-fetch and
re-render the sidebar.
Closes LOBE-10441
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(agent-builder): isolate editingAgentId to avoid message ownership desync
Per code review: the previous fix overrode state.metadata.agentId with the
editing target, but messages are already written with persistAgentId =
resolvedAgentId (the builder builtin). AgentRuntimeService.queryUiMessages
reads metadata.agentId to filter messages, so overriding it would cause the
gateway handler to snapshot the wrong topic and desync the builder conversation.
Correct approach: keep agentId as the builder builtin throughout. Carry
editingAgentId as a separate metadata field that only flows through to
ToolExecutionContext, where the AgentBuilder server runtime reads it via
ctx.editingAgentId ?? ctx.agentId. No other part of the pipeline is affected.
Changes:
- apps/server/src/services/aiAgent/index.ts: revert agentId override; keep
editingAgentId as an independent appContext field (conditional spread)
- apps/server/src/services/toolExecution/types.ts: add editingAgentId to
ToolExecutionContext
- apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts: forward
state.metadata.editingAgentId into the ToolExecutionContext
- apps/server/src/services/toolExecution/serverRuntimes/agentBuilder.ts:
use ctx.editingAgentId ?? ctx.agentId in all three write methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 💄 style(chat): add breathing room around message refresh hint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(chat): keep refresh hint top flush, widen bottom gap to 24px
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(chat): show cached message refresh hint
* 🐛 fix(chat): show refresh hint for store-backed cache
* 🐛 fix(chat): wait for model config before agent notice
* 🐛 fix(agent-runtime): forward model extend params on server-side agent runtime
Share the model extend-params resolution between the client chat service and
the server-side agent runtime so reasoning/thinking params (e.g. Gemini's
thinkingLevel) actually reach the request. Previously only the client resolved
them, so server-driven agent runs returned empty thought summaries.
- extract applyModelExtendParams into @lobechat/model-runtime
- client resolveModelExtendParams delegates to the shared core
- server RuntimeExecutors resolves extendParams (with canonical-card fallback
for aggregation providers like lobehub) and forwards them in the payload
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(agent-runtime): mock applyModelExtendParams in agent-runtime suites
The executor now imports applyModelExtendParams from @lobechat/model-runtime,
which these suites mock as a fixed object. Add the new named export (returning
an empty result, preserving prior payload behavior) so the mocked module
resolves and call_llm can run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(sidebar): anchor spacer immediately after the accordion block
The home sidebar spacer (`__spacer__`) drifts away from the recents+agent
accordion block in two reachable cases: (1) the dropdown-menu "move recents/
agent up/down" leaves the spacer floating above the accordion, and the
CustomizeSidebarModal then silently relocates it on the next drag; (2)
`withAllKnownKeys` appends every missing default to the tail, so any future
top-group default would land in the bottom group for existing users.
Enforce a single invariant in the selector: the spacer always sits right
after the last accordion item. `normalizeSpacerPosition` re-anchors on read
so legacy state self-heals, `withAllKnownKeys` splits backfilled defaults
into top vs bottom by their position in `DEFAULT_SIDEBAR_ITEMS`, and
`reorderSidebarItems` normalizes its result and returns the input reference
when the move is a visible no-op so callers' `next === items` short-circuit
still fires.
* 🐛 fix(sidebar): keep customize drag overlay within modal context
* 🐛 fix(sidebar): apply customization after confirm
Add a listGitWorktrees read that powers a worktree picker on both the
local desktop (IPC) and remote device (gateway) paths, mirroring the
existing branch/working-tree read plumbing.
- local-file-shell: parse `git worktree list --porcelain -z`, mark the
current worktree and attach dirty-file status per worktree
- desktop GitController IpcMethod + electron client service
- deviceGateway.listGitWorktrees + device.listGitWorktrees TRPC procedure
- DeviceGitWorktreeListItem type + useFetchGitWorktrees SWR hook
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(fleet): add lab-gated Fleet running-tasks dashboard
Side-by-side board of all running tasks across the account. The running-task
list is portaled into the NavPanel (replacing the standard nav rail), and each
task renders as a resizable, reorderable conversation column with its own
ChatInput. Columns default to every running task, support drag reorder, width
resize (persisted), close and a "+" to re-add. Gated behind the `enableFleet`
lab flag (Settings → Advanced → Labs); the title-bar entry is hidden by default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(fleet): add store unit tests for column reorder/add/remove/width
Covers seedColumns (seed-once), addColumn (dedupe), removeColumn,
reorderColumns (the dnd onDragEnd path) and setWidth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(fleet): running-topic data source, X-axis drag lock, hover border, full-width back bar
- Data source switched from the task running-group to all topics whose
status is `running` (one column per running topic). getAllTopics is
filtered client-side; a server-side getRunningTopics query is a planned
follow-up for accounts with many topics.
- Reorder drag is now locked to the horizontal axis (inline dnd-kit modifier).
- The resize-handle highlight only shows when hovering the handle itself,
not anywhere on the column.
- Back-to-home now lives in a full-width SideBarHeaderLayout top bar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(fleet): server-side queryTopics, drag border, loading-state input
- Replace getAllTopics with a `queryTopics` query that filters by status
server-side (topicModel.queryTopics + lambda TRPC + topicService). The
board now pulls only running topics instead of the full topic set, and the
unused getAllTopics procedures (lambda + mobile) and queryAll are removed.
- Dragging a column shows a primary border ring instead of dimming the column.
- ChatInput renders its loading skeleton while the column's messages load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 fix(fleet): reuse StatusDot, topic-first header, on-demand reply, create-task entry
- Status: reuse the app's StatusDot (running = warning spinner) instead of a
bespoke badge; drop StatusBadge/status.ts.
- Column header: topic title is now the primary line; agent name + avatar +
status moved to a smaller secondary line.
- Reply: each column's always-on ChatInput is replaced by a "Reply" button
that reveals the input on click (lower pressure).
- Sidebar: add a "Create task" button (createTaskModal) above the list.
- Drag: dragging a column shows a fill tint + 1px border instead of the 2px
primary ring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(fleet): observation tab, status spinner reuse, column menu/op-tray/workdir, agent-picker add
- Create-task entry moved into the sidebar header (next to the title).
- Column "open in chat" icon replaced by a ⋯ menu; the action now opens a
new Electron tab via electron addTab.
- Fleet route shows "Observation Mode" as its tab title (fleetRouteMeta).
- Each column shows its topic working directory + live git branch under the
agent name (useFetchGitInfo).
- Dragging a column is opaque now (solid bg + 1px border), not see-through.
- OpStatusTray added to each column to surface running-op progress / tokens.
- Trailing "+" opens an agent picker (AssigneeAgentSelector); selecting an
agent creates a fresh topic and opens it as a new column. Empty board keeps
the "+" available.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(fleet): align topic creation params
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(file): only enforce chat upload file-type whitelist in chat mode
The chat upload file-type whitelist rejects files that agents can readily
parse via tool calls (zip, html, provisioning profiles, files without an
extension, etc.), which hurts agent and heterogeneous-agent workflows where
the whitelist adds no value.
Scope the whitelist to plain chat mode only: `uploadChatFiles` now takes the
conversation's agent id and skips type validation when that agent has agent
mode enabled or is heterogeneous (Claude Code / Codex, etc.). The decision is
keyed off the input/conversation agent id via the by-id selectors rather than
the global current agent, because the chat input can be scoped to a different
agent than activeAgentId (e.g. another desktop tab). Closes#15770.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🚨 fix: sort imports in file chat action to satisfy lint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(group): add server-side group orchestration (call agent member)
Mirror the client GroupOrchestrationRuntime on the server: the supervisor's
own durable QStash operation drives the loop, with lobe-group-management
registered as a server deferred tool. speak/broadcast/delegate run members in
the shared group session via execAgentMember; executeAgentTask(s) reuse the
isolated sub-agent thread. A K=N member barrier backfills the group tool
message and resumes (or finishes, for skipCallSupervisor/delegate) the parked
supervisor through the existing async-tool bridge + CAS. Adds the
group-member-callback QStash webhook for queue mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(group): address PR review (finish disposition, ephemeral prompt, task timeout)
- finish-vs-resume now scans ALL pending tools, not just pending[0], so a
group skipCallSupervisor/delegate call that isn't the first deferred tool in
a batched turn no longer wrongly schedules a resume.
- in-group member instructions are injected as ephemeral LLM context
(execAgent: suppressUserMessage + new ephemeralUserMessage) instead of being
persisted as real `role: 'user'` group messages — matches the client's
virtual supervisor instruction.
- isolated executeAgentTask(s) now enforce the requested timeout: a watchdog
interrupts the member and bridges a `timeout` completion so the supervisor
resumes/finishes instead of staying parked indefinitely.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Server-triggered heterogeneous-agent runs forked the message chain on a
remote-device WS reconnect: several consecutive, distinct main-agent steps
all parented onto the run's FIRST tool message instead of chaining linearly,
leaving orphan sibling assistants.
The chain rule (`computeTurnParentId = lastToolMsgIdEver ?? currentAssistantId`)
relies on in-memory reducer state. On a non-sticky / cold replica the state is
rebuilt from DB by `refreshMainStateFromDb`, which anchored off
`getLastChildToolMessageId(currentAssistantId)`. When `heteroCurrentMsgId` is
not yet bound to the operation, `currentAssistantId` regresses to the seeded
placeholder assistant, so the anchor collapses to the seed's first child tool
and every later step opens off that same node. The class already documents the
"must be sticky to a single replica" caveat — the remote-device path breaks it.
Anchor the chain to the run's real latest main-thread tool instead, read from
the DB and ordered by createdAt, independent of currentAssistantId. Scope to
the run via the seed assistant's createdAt floor (messages carry no operationId,
and a topic runs at most one operation at a time). This also sidesteps the
multi-tool-batch hazard where an earlier tool's result_msg_id is backfilled
before a later tool row's JSONB is rewritten.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Previously the "+" button ran the active route's createNewTab handler, so
on an agent/group/page tab it created a new topic/page of that same kind.
Make it always open Home instead, and remove the now-dead createNewTab
route-meta machinery.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): stop background config fetch from hijacking the active agent
Switching to or opening an agent tab could flash the conversation
header/welcome back to the inbox "Lobe AI" identity. Two causes:
- `useFetchAgentConfig.onData` set the global `activeAgentId` to whatever
config resolved, so a background/secondary fetch (the inbox config from
the home input, a side-panel copilot, or another open tab) hijacked the
routed agent. It now only adopts the fetched agent when none is active;
route-level sync (AgentIdSync on desktop/mobile, the popup pages' own
setState) owns `activeAgentId`.
- `AgentInfo` (the agent conversation welcome) read the global
`currentAgentMeta` / `isInboxAgent`. Scope it to the conversation's agent
via `useConversationStore(contextSelectors.agentId)` + `*ById` selectors,
so it renders the routed agent even if the global races.
Also remove the dead `Conversation/AgentWelcome/{index,OpeningQuestions}`
(the conversation welcome is `AgentHome`/`AgentInfo`; this variant was
unreferenced).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent): scope agent conversation subtree to explicit agentId (LOBE-10402 phase 1)
Replace global `current*` selectors (which implicitly read the hijack-prone
`agentStore.activeAgentId`) with `*ById(agentId)` in the agent conversation
subtree and two shared features. The agentId is sourced explicitly:
- inside the ConversationProvider → `useConversationStore(contextSelectors.agentId)`
(MainChatInput, AgentConfigError, HeterogeneousChatInput, ToolAuthAlert, TTS,
ShareImage, History)
- ConversationArea → its own `context.agentId`
- above the provider → `useChatStore(s => s.activeAgentId)` (route-driven via
AgentIdSync) — ChatConversation, AgentSummary
- already-available id → prop (AgentTopicManager/Header) or resolved context
(ShareModal/ShareDataProvider)
Add the missing `getAgentTTSVoiceById` and `getAgentConfigErrorById` byId
selectors (+ tests). The `current*` selectors are left in place for now; they
are removed in the final phase once every caller is migrated.
Refs LOBE-10402.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): pass the scoped conversation agentId into the hetero guards
`useHeteroAgentCloudConfig` and `useRemoteAgentDeviceGuard` read the global
`activeAgentId` internally, so when the conversation agent differs from it
(the tab-hijack scenario), the cloud-credential and bound-device checks
validated a different agent than the one `agencyConfig`/`isDeviceExecution`
were computed from — the input could be enabled without the routed Claude Code
agent's credential check, or blocked with the wrong device status.
Both hooks now take the conversation `agentId` explicitly and read that agent's
agencyConfig by id, keeping every hetero check on the same routed agent.
Refs LOBE-10402.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(thread-list): cap nested thread list height with scroll overflow
When an active topic has many threads, the nested list grows unbounded and
pushes the rest of the topic list off-screen. Cap it at ~9 rows and scroll
the overflow within the list itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(thread-list): scroll the active thread into view in the capped list
The new max-height scroll container always mounts at scrollTop=0, so a thread
restored from the ?thread= query that sits below the visible rows stayed out of
view — and since the topic row isn't highlighted while a thread is active, the
sidebar showed no selection at all.
Add a shared useScrollActiveThreadIntoView hook that nudges the capped list so
the active row (marked via data-thread-id) is visible, keyed off the list-ready
signal so it also fires once async-fetched threads mount. Wired into both the
agent and group ThreadList variants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs(ux): add list selection-visibility & in-progress-edit rules
Distill two UX learnings from the capped thread-list work into the ux skill:
restoring an off-screen selection in a scrolled/capped/virtualized list must
scroll it into view, and editors must back up in-progress input locally so an
accidental exit, crash, or failed save can't vaporize the user's work.
Reorganize the checklist by interaction type (Read / Edit / Act / Feedback /
Grow) instead of a flat list, and use English-only headings and value tags.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(thread-list): make the capped nested list actually scroll
The cap was on the container but never engaged: the list is a flex column,
so the rows (default flex-shrink) compressed to fit max-height instead of
overflowing. Pin each row to min-height 36 so the content overflows, and
swap the wrapper to ScrollShadow so the cut-off shows an edge fade instead
of an invisible hard clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
# 🚀 LobeHub Release (20260615)
**Release Date:** June 15, 2026
**Since v2.2.4:** 48 merged PRs · 5 contributors
> This cycle lands the Composio integration as the new connector
backbone, a unified tiered client cache, and a deep round of
agent-runtime reliability hardening for cold-replica and sub-agent
flows.
---
## ✨ Highlights
- **Composio integration** — New Composio integration layer replaces
Klavis as the connector backbone for third-party skills. (#15461)
- **Tiered client cache** — Unified localStorage + IndexedDB cache
provider with per-scope isolation, plus a registry-wide convergence of
SWR keys for predictable invalidation. (#15844)
- **Gateway mode in chat config** — Gateway mode now lives in chat
config, making it per-conversation rather than a global toggle. (#15714)
- **Bulk move topics** — Move multiple topics to another assistant in
one action. (#15809)
- **Skills row actions** — View / rename / delete row actions in the
working sidebar, plus edit / uninstall for connectors in Skill detail.
(#15864, #15829)
- **Token usage cache rate** — Conversations now surface the
prompt-cache hit rate alongside token usage. (#15812)
---
## 🏗️ Core Agent & Architecture
- **Run lifecycle** — Extracted client run-completion into a shared
`buildRunLifecycle`, with a characterization net over agent-runtime
run-lifecycle. (#15854, #15843)
- **Sub-agent resilience** — Hardened async sub-agent suspend/resume
against missed wakeups. (#15855)
- **Cold-replica correctness** — Fixed main-turn idempotency and now
mark topics failed on terminal errors; persist sub-agent turn id so cold
replicas don't fragment a turn; dedupe sub-agent thread creation after
finalize. (#15838, #15808, #15849)
- **Stream routing** — Drop sub-agent-tagged events from the main
gateway stream handler, and preserve `subAgentId` / `documentId` in the
message bucket key context. (#15814, #15865)
- **Heterogeneous agents** — Forward bot / IM image attachments to
heterogeneous agents. (#15868)
- **Agent state** — Stop background config fetch from hijacking the
active agent, and warn when agent mode is on but the model lacks tool
calling. (#15862, #15828)
- **Tracing** — Enable S3 tracing by default in production. (#15841)
---
## 🔌 Integrations & Skills
- **Skill panel** — Dedupe skill-panel rows and allow deleting pending
integrations; stop connected integrations from duplicating in the
chat-input skill panel. (#15872, #15869)
- **Connectors** — Edit / uninstall buttons for connectors in Skill
detail. (#15829)
---
## 🖥️ Chat & User Experience
- **Topics** — Server-side status filter via a new `queryTopics` query,
and per-agent topic search scoped by `agentId`. (#15822, #15798)
- **Message rendering** — Render mixed assistant blocks in natural
order, fold short mixed tool blocks together, and render mention names
from the serialized attribute instead of falling back to "unknown".
(#15810, #15857, #15831)
- **Tool workflow** — Tool-workflow collapse no longer shows "in
progress" once content renders below it. (#15815)
- **Token usage** — Derive operation token usage from messages rather
than a parallel accumulation. (#15819)
- **Reconnect** — Normalize reconnect `startTime` to epoch ms. (#15811)
- **Home & editor** — Hide the agent-mode notice while config is
loading, and isolate the page-editor copilot context from global
agent/document state. (#15846, #15826)
- **Polish** — base-ui modal fixes the provider delete-confirm z-index,
the updater renders release notes as Markdown, revert-confirm and toast
copy tightened. (#15845, #15867, #15813)
- **Desktop** — Tray double-click opens the main window. (#15816)
---
## 🔒 Reliability
- **Auth gating** — Gate the `listDevices` request behind login state so
it no longer fires before authentication. (#15876)
---
## 🔧 Tooling & Internal
- **SWR convergence** — Converged store-, UI-, and straggler SWR keys
into the `swrKeys` registry, fixing a stale prefetch key along the way.
(#15863, #15858, #15853, #15850, #15848)
- **Tests** — Characterization coverage for parked states and
post-persist title wiring; removed stale `LOBE-XXX` markers; updated
testing skill rules. (#15847, #15852, #15807)
- **Docs** — Added the ux design-values / execution-checklist skill and
a capability-gated feature checklist. (#15823, #15832)
- **Misc** — Fixed workspace prefix handling; bumped
`@vitest/coverage-v8` to v3.2.6. (#15837, #15802)
---
## 👥 Contributors
Huge thanks to **5 contributors** who shipped **48 merged PRs** this
cycle.
@arvinxx · @LiJian · @Innei · @tjx666 · @Rdmclin2
Plus @lobehubbot and renovate[bot] for maintenance.
---
**Full Changelog**: v2.2.4...release/weekly-20260615
Devices are served by an authed lambda procedure, but the client fired
`device.listDevices` unconditionally — `useEffectiveWorkingDirectory`
(broadly mounted in chat) and `WorkingDirectoryPicker` both called
`useFetchDevices()` with no argument, so logged-out web users sent a bare
request that 401s. The settings `DeviceList` queried it directly with no
`enabled` gate too.
Thread `isLogin` (|| isDesktop, matching `useInitUserState`) into all three
call sites and flip `useFetchDevices`'s default to `false` so the safe
default is opt-in.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(skill): dedupe skill panel rows and allow deleting pending integrations
Two related fixes for the chat-input "+" → Skills panel:
1. Dedupe by key: the same app can be sourced from more than one list
(a Composio/LobeHub integration item plus an installed plugin sharing the
same identifier), which rendered the row multiple times. Add a key-based
dedup pass on the final skill list, keeping the first (richer) occurrence.
2. Deletable pending integrations: a Composio server that exists but isn't
ACTIVE (pending auth / re-authorize — e.g. after closing the OAuth popup)
only rendered a Connect/Re-authorize link with no "..." menu, so it could
never be removed. Give these rows a delete-only policy menu (via the "..."
button and right-click) backed by removeComposioConnection, while keeping
the Re-authorize action. renderPolicyMenu gains a `deleteOnly` mode that
hides the meaningless Pinned/Auto options for not-yet-connected entries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(skill): drop optimistic plugin id when deleting a Composio connection
handleConnect adds the new server id to the agent's plugins before OAuth
completes, so removeComposioConnection alone left an orphan id in the config:
the row stayed counted as pinned, and a later reconnect's togglePlugin flipped
the freshly-connected skill back off. Wrap removal so it also unpins the id via
togglePlugin(id, false) (a no-op when absent), for both active and pending
delete paths.
Addresses Codex review feedback on #15872.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔨 chore(skill): make Composio plugin-id cleanup best-effort on delete
Swallow togglePlugin failures so the optimistic plugin-id cleanup can never
break the actual connection removal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(skill): allow removing orphaned Composio entries with no server
A Composio app whose id lingers in the agent's plugins but has no server yet
(added optimistically, never authorized) rendered a plain "Connect" row with no
"..." menu, so it couldn't be removed. Surface such ids in the list and give
them the same delete-only menu (via "..." and right-click) as pending servers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Bot/IM channels (Slack, Telegram, …) deliver attachments as raw `files`
buffers, while the SPA gateway delivers pre-uploaded `fileIds`. The
heterogeneous-agent branch of `execAgent` forked early and only handled
`fileIds`, so images sent through a bot were silently dropped — the CLI
(Claude Code / Codex) received text only.
Unify the turn setup so both branches share one implementation:
- Extract `resolveRunAttachments` (raw `files` → S3 via ingestAttachment +
`attachedFileIds` → resolveAttachmentsByFileIds), returning
{fileIds, imageList, videoList, fileList, warnings}; attachment resolution
is non-fatal.
- Hoist attachment ingestion + user-message + assistant-placeholder creation
above the hetero/normal fork; both branches consume the same records.
- Exclude the freshly-created turn from `loadHistoryMessages` via a
`selfMessageIds` set so the prompt isn't double-counted in the LLM context.
- Assistant-placeholder fields stay conditional (hetero seeds provider only;
the CLI reports the real model later). Agent Signal stays normal-only.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add hover-revealed action buttons and a shared right-click context menu to
skill rows across project, agent, and user skill lists in the working
sidebar, plus a shared RenameSkillModal.
- SkillsList: per-row `getRowActions` descriptor drives both the hover icon
cluster and the context menu; disabled actions render greyed for
not-yet-supported operations
- User skills: view (detail modal), rename (user-authored only), delete
- Agent skills: view/rename/delete via the agent-document service
- Project skills: view (local only); rename/delete stubbed "coming soon"
until the filesystem-mutation IPC lands
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chat-input "+" → Skills panel listed connected integrations (Gmail,
Google Calendar, Google Drive, etc.) twice: once as a brand-icon item under
the LobeHub group, and again as a generic plug-icon "community plugin".
Root cause: community plugins were filtered with a blacklist
(`type !== 'customPlugin'`), so integration gateway plugins whose source is
`'self'`/`'builtin'` leaked into the community group. The /settings/skill
page already avoids this by whitelisting `type === 'plugin'`. Align the
chat-input panel with the same whitelist.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Introduce the unified store/UI run-lifecycle contract (AgentRunLifecycle) + a
buildRunLifecycle factory, and wire the CLIENT streaming runtime through it.
Behavior-preserving (strategy A): the client completion effects are relocated
verbatim into the factory hooks, so the characterization net stays green.
- runLifecycle/types.ts — AgentRunLifecycle contract: 9 lifecycle hooks incl.
onRunParked/onRunResumed, carrying a runId that survives across operations and
a runScope gate. Explicitly separate from the runtime-internal BLOCKING hooks.
- runLifecycle/buildRunLifecycle.ts — factory implementing the client effect set
(afterCompletion → drain/requeue → completeOperation/markUnread → normalized
client.runtime.complete signal → desktop notification). normalize/findCompletion
helpers relocated here.
- streamingExecutor — completion block replaced by completeRun + afterRunComplete
calls; dead emit closure removed.
Gateway/hetero adapters + hoisting the assembly to the sendMessage seam land in
LOBE-10379. No behavior change: streamingExecutor net 43/43, sibling suites 79/79,
type-check + eslint clean.
Part of LOBE-10376
Closes LOBE-10378
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
chore: remove LOBE-XXX markers from streamingExecutor characterization tests
- Replace LOBE-10377 with cross-transport baseline description
- Replace LOBE-10382 with parked/resumed/terminal signal normalization context
- Preserve test semantics — comments now explain intent without Linear ticket references
Co-authored-by: Arvin Xu <arvinx@lobehub.com>
* 🐛 fix(chat): preserve subAgentId/documentId in message bucket key context
`replaceMessages` and `internal_getConversationContext` rebuilt the
conversation context with a hand-picked field whitelist, silently dropping
`subAgentId` (and others). Since `messageMapKey` uses `subAgentId` as the
group_agent scope subTopicId, group-agent writes collapsed into the wrong
bucket. Spread the whole context instead and only special-case the fields
that need a fallback/assertion (agentId, topicId), so every bucket-key
field carries through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(database): deterministic ordering in topic.duplicate test
Both seed messages were inserted in one transaction with no explicit
createdAt, so they shared the same `now()` default. `duplicate`'s
`orderBy(createdAt)` then returned the tied rows in arbitrary order,
making the positional assertions flaky. Give them distinct createdAt
(user before assistant) so the order is well-defined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
🐛 fix(updater): render release notes as Markdown instead of raw source
The update modal injected release notes via dangerouslySetInnerHTML, but
the content is a Markdown source string (e.g. `## Canary Build`, GFM
tables), so headings/tables/bold were shown literally as raw text.
Render it with @lobehub/ui's <Markdown> component instead. Also handle the
`ReleaseNoteInfo[]` shape of `releaseNotes` by rendering each note.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Switching to or opening an agent tab could flash the conversation
header/welcome back to the inbox "Lobe AI" identity. Two causes:
- `useFetchAgentConfig.onData` set the global `activeAgentId` to whatever
config resolved, so a background/secondary fetch (the inbox config from
the home input, a side-panel copilot, or another open tab) hijacked the
routed agent. It now only adopts the fetched agent when none is active;
route-level sync (AgentIdSync on desktop/mobile, the popup pages' own
setState) owns `activeAgentId`.
- `AgentInfo` (the agent conversation welcome) read the global
`currentAgentMeta` / `isInboxAgent`. Scope it to the conversation's agent
via `useConversationStore(contextSelectors.agentId)` + `*ById` selectors,
so it renders the routed agent even if the global races.
Also remove the dead `Conversation/AgentWelcome/{index,OpeningQuestions}`
(the conversation welcome is `AgentHome`/`AgentInfo`; this variant was
unreferenced).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(swr): converge the last straggler SWR keys + fix stale prefetch key
Final cleanup of the SWR key convergence. Migrates the remaining ad-hoc keys
that earlier grep-based sweeps missed (they hid behind non-obvious const names
like SWR_KEY / FETCH_*_KEY / SWR_RESOURCES, template-literal keys, the electron
store, and assorted one-off hooks):
- hooks: usePrefetchAgent, useHomeDailyBrief, useGatewayReconnect
- features: OpenInAppButton, Recommendations/useHeteroDetections,
RecommendTaskTemplates, ResourceManager search
- routes: provider ClientMode + DisabledModels (useSWRInfinite), memory
analysis task, sidebar task groups, imessage bridge status, Review git patches
- store: user initState + checkTrace, builtin agent init, file resources,
electron settings/gateway/sync
New registry domains: home, taskTemplate, resource, provider, recommendations,
openInApp, gateway, user, builtinAgent, imessage, sidebar, electron — plus
extensions to aiModel (disabledModelsPage), device (gitReviewPatches /
gitRemoteBranches), userMemory (analysisTask).
🐛 Fix: usePrefetchAgent warmed `['FETCH_AGENT_CONFIG', agentId]`, which never
matched what `useFetchAgentConfig` reads. It now warms
`augmentKey(agentConfigKeys.config(agentId), getActiveWorkspaceId())` — the
exact workspace-scoped key the consumer subscribes to, so hover-prefetch
actually populates the cache.
No tiering/caching change: every new prefix is kept out of CACHE_TIERS
(names avoid the cached agent:/task:/brief: tiers). The electron factory roots
retain their original `electron:getXxx` strings, so those cache identities are
unchanged.
After this, the only ad-hoc SWR keys left are in `packages/*` (can't import
`@/libs/swr/keys`); every `src/` SWR call site now routes through the registry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(swr): drop suspense: true from data-fetching hooks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(swr): update refreshUserState assertion to registry key
Follow-up to the prior commit: the auth-slice test still expected
mutate('initUserState'); refreshUserState now passes userKeys.initState()
(['user:initState']). Assert against the factory so it tracks the registry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Completes the SWR key convergence by migrating the remaining UI-layer ad-hoc
keys (features / routes / components) into the central registry. New domains:
stats, messenger, verify, inbox, share, fork, portal, favorite, changelog,
onboarding, agentHome, agentProfile, agentSignal, ollama, auth, cron,
topicAction — plus extensions to discover (mcpAgents/skillAgents/market),
device (gitBranches/repoType), session (createSession), group (queryAgents*).
- Shared keys (availablePlatforms, agentsForBinding, bindingScopes,
shared-topic, favorite-status, openNewTopicOrSaveTopic, portal-document-header,
inbox notifications/unread) are routed through one factory at every call site
so they still dedupe to a single cache entry.
- The notifications useSWRInfinite getKey and the userMemory-style matcher
invalidations were migrated in lockstep with their fetch keys.
- No tiering/caching change: every new prefix is kept out of CACHE_TIERS, and
names avoid the cached prefixes (share:/portal:/agentHome:/agentProfile: etc.
instead of topic:/document:/agent:). Behavior preserved.
- Folds in the lone cross-layer `cronTopicsWithJobInfo` store mutate.
Packages (builtin-tool *) keep their local keys — they can't import from
`@/libs/swr/keys`; left as-is intentionally.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(composio): add Composio integration layer as Klavis replacement
- Add @composio/core SDK client factory (src/libs/composio)
- Add COMPOSIO_API_KEY server config + enableComposio flag
- Add COMPOSIO_APP_TYPES const with 21 curated apps (appSlug-based)
- Add lambda/composio tRPC router (createConnection, deleteConnection, getConnection, updateComposioPlugin)
- Add tools/composio tRPC router (executeAction, listActions, getActions)
- Add ComposioService with executeComposioTool + getComposioManifests
- Add composioStore Zustand slice (7 files: types, initialState, action, selectors, index, test)
- Wire composioStore into ToolStore state and action tree
- Add composioStoreSelectors to tool selectors index
- Add handleComposioInstall to AgentManagerRuntime
- Extend CustomPluginParams with composio field
- Add enableComposio to GlobalServerConfig types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🔥 refactor(klavis): remove Klavis integration and migrate all references to Composio
- Delete all Klavis source files (libs, config, const, routers, services, store, UI components)
- Rename KlavisX components to ComposioX equivalents
- Replace all Klavis store selectors, types, and action names with Composio counterparts
- Fix authConfigId to be server-side managed (auto-fetch/create from Composio API)
- Update DB customParams.klavis → customParams.composio throughout
- Fix ToolSource type: 'klavis' → 'composio'
- Fix TaskTemplateSkillSource: 'klavis' → 'composio'
- Fix RecommendedSkillType.Klavis → RecommendedSkillType.Composio
- Remove klavis npm package dependency
- Update builtin-tool-creds: connectKlavisService → connectComposioService
- Update RuntimeExecutors: KLAVIS_SERVICES_LIST → COMPOSIO_SERVICES_LIST
- All Composio-related type errors: 0 remaining
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(composio): complete the klavis→composio migration and wire the OAuth callback
The composio branch had renamed the klavis modules but left consumers
half-migrated, so the OAuth connect link did not work end-to-end. Finish it:
- Add the missing OAuth callback route `/api/composio/oauth/callback` (Composio
uses managed auth, so it only lands the user back and closes the popup; the
opener then polls getConnection and syncs tools). Allowlist it as a public
cross-site redirect landing in the proxy define-config.
- Remove leftover `import { type Klavis } from 'composio'` (non-existent package)
and type the prop as `string`.
- Fix undefined `oauthUrl` → `redirectUrl` in every OAuth popup opener.
- Map `serverName` to `appSlug` (API) / `label` (display); unify every
createComposioConnection call to `{ appSlug, identifier, label }`.
- Compare against the `ComposioServerStatus` enum instead of the `'ACTIVE'`
string literal.
- Use the renamed store fields `composioServers` / `isComposioServersInit`.
- executeComposioTool: `toolName` → `toolSlug`.
- Rename onboarding `KlavisServerItem.tsx` → `ComposioServerItem.tsx` to match
its import.
* 🐛 fix(composio): use connectedAccounts.link for Composio-managed OAuth
`connectedAccounts.initiate` is no longer supported for Composio-managed OAuth
auth configs (HTTP 400), which broke connecting apps like Gmail. Switch to
`connectedAccounts.link` (POST /api/v3/connected_accounts/link) — same
`{ callbackUrl }` options and `{ id, redirectUrl }` result, so it is a drop-in.
Also treat Composio's `status=failed` callback query param as a failed
authorization in the OAuth callback page.
* 🐛 fix(composio): correct tool sync, execution, callback build, and list dedup
Four fixes found while testing the Composio integration end-to-end:
- listActions: use `getRawComposioTools` (raw defs with slug/inputParameters)
instead of `tools.get()` (provider-wrapped, name/params under `.function`).
The wrapped shape left every synced tool with an empty name, so they all
collapsed to `${identifier}____` and the LLM rejected the request with
"Tool names must be unique."
- tools.execute: pass `dangerouslySkipVersionCheck: true` (manual execution
otherwise throws ComposioToolVersionRequiredError when the toolkit version
resolves to "latest"). Applied to both the executeAction router and the
ComposioService used by the agent runtime.
- OAuth callback route: escape only `<`/`>`/`&` for the inline-script payload;
the previous regex embedded literal U+2028/U+2029 line separators which broke
the regex literal at build time ("Unterminated regular expression").
- installed-plugin selectors: filter out `customParams.composio` (was still
checking the old `customParams.klavis`), so a connected Composio app no longer
shows up twice in the skill picker / tool discovery list.
* ✨ feat(composio): pin auth config id per toolkit via env
Add `COMPOSIO_AUTH_CONFIG_IDS` (JSON map of `identifier -> authConfigId`) so a
pre-created Composio auth config (e.g. a custom/white-label OAuth app set up in
the dashboard) can be used directly per toolkit. `createConnection` now resolves
the pinned auth config first, then falls back to discovering an existing one for
the toolkit (matched case-insensitively), and only auto-creates a
Composio-managed config when nothing is configured.
* 🐛 fix(composio): update plugin invoke test to composio + sort tool initialState imports
- action.test.ts: the action was renamed invokeKlavisTypePlugin → invokeComposioTypePlugin
(Klavis is being removed); update the test to call the composio action and drop
the klavis-era naming/mock field.
- store/tool/initialState.ts: order the composioStore import before connector to
satisfy simple-import-sort/imports.
* 🐛 fix(composio): stop client deleting remote connections by static allowlist
useFetchUserComposioConnections no longer deletes remote connections/plugins
for identifiers outside the compile-time COMPOSIO_APP_TYPES list — an outdated
client bundle would silently destroy a legitimate connection. Unknown
identifiers are now only hidden locally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(composio): resolve connectedAccountId server-side in executeAction
executeAction now takes `identifier` and looks up the connectedAccountId from
the caller's own user-scoped plugin record (PluginModel), instead of trusting a
connectedAccountId supplied by the client — which would let a user drive
another user's connection. Callers (callComposioTool, composioExecutor) pass
identifier accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(composio): enable plugin only after OAuth succeeds
Move enablePluginForAgent into the ACTIVE and post-auth-success branches so a
cancelled/timed-out authorization no longer leaves an enabled-but-unauthorized
Composio tool on the agent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🔥 fix(composio): drop dead OAuth callback postMessage
The lobe-composio-oauth postMessage had no consumer — the OAuth wait uses
polling + window.closed detection. Remove it and its escaping helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(composio): resolve type-check errors after canary merge
- Guard authConfigId to a definite string before persisting/returning it
(createConnection), fixing the string|undefined assignment in both the
server router and the composio store server object.
- Replace leftover KLAVIS_SERVER_TYPES with COMPOSIO_APP_TYPES in AgentTool.
- Update SkillAuthRow test to a composio source/provider (klavis is removed
from TaskTemplateSkillSource).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(composio): remove leftover klavis naming after migration
Klavis is deprecated and fully replaced by Composio. The migration kept the
underlying composio wiring but left klavis-named identifiers, comments, prompt
tags, i18n keys, and files throughout. Sweep them to composio:
- Code identifiers/comments across ~70 files (isKlavisEnabled→isComposioEnabled,
allKlavisServers→allComposioServers, klavisManifests→composioManifests, etc.)
- LLM prompt tags (<klavis_tools>→<composio_tools>, KLAVIS_SERVICES_LIST→
COMPOSIO_SERVICES_LIST) — kept consistent across definition and substitution
- i18n keys tools.klavis.*→tools.composio.* + user-facing "Klavis"→"Composio"
brand strings, in default setting.ts and all locale setting.json files
- Rename useKlavisOAuth→useComposioOAuth, useKlavisServerActions→
useComposioServerActions (+ imports)
- klavis.ai homepage URLs → composio.dev
- Remove the dead `klavis` npm peerDependency; swap .env.example Klavis section
for Composio; update product docs
Changelog history left untouched. Pure rename — no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(composio): remove duplicate composio key in CustomPluginParams
The klavis→composio rename collapsed the deprecated klavis param block onto
the live composio one, producing a duplicate `composio` property. The klavis
shape (instanceId/serverName/serverUrl/isAuthenticated) is dead — no code reads
it — so drop it and keep the live composio shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(composio): let pending/errored connections re-authorize or be deleted
A Composio connection link (lk_...) expires after a while. Previously a
pending/errored row only offered to reopen the stored — now expired —
redirectUrl, and the delete action existed only for ACTIVE connections, so an
expired link left the tool permanently stuck: unauthenticatable and
unremovable.
- Add reauthorizeComposioConnection store action: best-effort delete the stale
connection, then mint a fresh link (replaces the record in place)
- Settings skill item + chat toolbar item: PENDING/ERROR now render a ··· menu
with Re-authorize (fresh link) and Delete
- Onboarding: pending/errored row click re-mints a fresh link instead of
reopening the stale one
- i18n: add tools.composio.reauthorize (en-US + zh-CN)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(composio): return auth link instead of opening popup from agent
connectComposioService runs from the agent's response, which carries no user
gesture, so window.open was blocked by the browser and the flow always failed
with "Authorization was cancelled or timed out". Instead of opening the popup
ourselves, return the authorization redirectUrl in the tool result so the agent
can surface a clickable link — the user's click is a real gesture and completes
the OAuth normally. Drops the now-unused popup/poll helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 💄 fix(composio): match pending toolbar item to sibling authorize affordance
The ··· dropdown I added to the chat-toolbar Composio item was a bare icon
(inconsistent color/size with the app's standard menus), its popup was
mis-anchored/offset, and replacing the visible "authorize" cue with a ···
made an un-authorized (pending) row look connected.
Match the sibling LobehubSkillServerItem instead: render a clickable
"Re-authorize" text + external-link icon for PENDING/ERROR. Clicking re-mints a
fresh link (the prior one may have expired) and opens it. No dropdown, so no
offset; the explicit affordance makes it clear the row still needs auth. Delete
stays on the settings page (siblings have no inline delete here either).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(agent-runtime): harden async sub-agent suspend/resume against missed wakeups
The server callSubAgent async park/resume chain (#15481) had a one-shot,
no-retry recovery: a single transient miss left the parent stuck in
waiting_for_async_tool forever. Harden the resume barrier and watchdog
(LOBE-10385 parts 1-3, 5; the park-side deadline fallback follows separately):
- Read-your-writes barrier: completeSubAgentBridge passes the just-backfilled
toolMessageId to the barrier, which trusts that local write instead of
re-reading message_plugins from a possibly-stale read replica.
- Bounded backoff watchdog: verifyAsyncToolBarrier now re-arms with exponential
backoff (15s→30s→60s→120s→240s, 5 attempts) until the barrier passes or the
op is terminal, replacing the single 15s shot that never re-armed.
- Plug silent bails: !state and pending.length===0 now warn + emit a metric;
the empty-pending case also arms a fallback verify for snapshot-persist lag.
- Observability: new agent_runtime_async_tool_resume_total counter keyed by
outcome (resumed/barrier_held/no_pending/no_state/lost_cas/verify_exhausted)
so missed wakeups surface instead of accumulating silently.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(hetero): reconstruct queued upload files from filesPreview on run continuation
When continuing a heterogeneous agent run with remaining queued messages, rebuild
the upload file items from filesPreview metadata instead of passing bare { id }
stubs, so file context (name/type/preview) survives the continuation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
♻️ refactor(swr): converge remaining discover/tool/global/userMemory store keys
Completes the store-layer SWR key convergence into the central registry
(batch3 only partially covered discover). Migrates the remaining ~39 ad-hoc
keys:
- discover: model/plugin/provider/skill/mcp/groupAgent list+detail+categories
and user profile (the `.join('-')` string keys → registry array factories).
- tool: agentSkills, installedPlugins, builtin uninstalled-tools, lobehubSkill
store, mcpPluginList, klavis store. (The dynamic `plugins`-array key is left
as-is — it's data-derived, not a named key.)
- global: latest/server version, system status.
- userMemory: retrieve / memoryDetail / activities / contexts / experiences /
identityList / preferences. The `purgeAllMemories` invalidation was rewritten
from `startsWith('useFetch…')` string matchers to array `key[0] === *.root`
matchers, in lockstep with the fetch keys.
No tiering/caching change: all new prefixes (discover/tool/global/userMemory)
are kept out of CACHE_TIERS, so everything stays memory-only as before.
Behavior preserved (key identity, mutate match sets, personal-vs-workspace).
UI-layer keys + the cross-layer `cronTopicsWithJobInfo` remain for the next PR.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The subagent run coordinator keys thread creation purely on the in-memory
`runs` map. On a cold serverless replica / BatchIngester retry the map is
empty, and `refreshSubagentRunsFromDb` only rehydrates `Processing` isolation
threads — a spawn that already finalized (thread flipped `Active`) is excluded.
So a replayed first-event for a finished subagent hits the `!existing` branch of
`ensureRun` and forks a SECOND thread with the identical title ("一模一样的两个
thread"). Sibling of #15838 (main-turn) / #15808 (subagent-turn), but for the
thread-create step.
Fix: give thread creation a DB-homed, status-independent idempotency guard keyed
by `sourceToolCallId`.
- `SubagentRunsState` gains `finalizedParents: Set<string>`; `finalizeRun`
records the parent there (instead of just deleting the run), so `ensureRun`
returns a no-op for a replayed finished spawn — no duplicate thread or message.
- `refreshSubagentRunsFromDb` seeds `finalizedParents` from this operation's
`Active` isolation threads (without resurrecting them as live runs, which would
mint empty assistants / re-finalize churn).
Regression: subagent reducer unit test (finalize → replay first event → 0
intents) + handler cold-replica test (finished subagent replay → still 1 thread).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
♻️ refactor(swr): converge remaining store-layer keys into swrKeys registry
Migrate all ad-hoc SWR keys still living in the store/service layer onto the
central registry (src/libs/swr/keys.ts), under the uniform `domain:resource`
naming. New domains: discover, eval (agent eval), ragEval, knowledgeBase,
device (incl. git), userMemory, agentKnowledge, agentBot, file, chatTool.
- Pure key convergence: no tiering/caching change. The new prefixes are kept
deliberately OUT of CACHE_TIERS, so every migrated key stays memory-only
exactly as before (agentKnowledge:/agentBot: avoid the cached `agent:` tier).
- Behavior preserved: key array shapes, mutate matchers (key[0] === *.root),
and personal-vs-workspace match semantics are unchanged; string-join keys
(discover assistant/social) become arrays with equivalent identity.
- UI-embedded SWR keys (features/routes/components/packages) intentionally left
for a later pass.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(swr): migrate session/thread/recent/group-list keys into swrKeys registry
Batch 1 of the SWR key centralization: add session/thread/recent keys and
group:list to the registry under the domain:resource convention, migrate call
sites + mutate matchers, update the localStorage tier patterns (recent:list,
group:list), and update tests. Removes the ALL_RECENTS_DRAWER_SWR_PREFIX export
in favor of recentKeys.allDrawer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(swr): version+unify message key, drop isLogin from keys, migrate agent/aiModel/image/video/serverConfig
- message: drop `listLegacy`; both stores use the accurate `message:list` key,
now carrying MESSAGE_CACHE_VERSION; fix the chat store `refreshMessages` to
invalidate the real key via a context matcher (was a dead key, never matched).
- keys: remove the redundant `isLogin` arg from all list factories (the app is
always authenticated); drop the now-unused isLogin param from useFetchSessions.
- migrate agent config/available/search, aiModel, image+video generation, and
serverConfig keys into the registry; update call sites, mutate matchers, tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(swr): restore isLogin arg in list keys
Re-introduce the isLogin argument across the session/agent/group/recent/brief
list key factories and their call sites (incl. useFetchSessions). The key must
vary with auth state so login/logout transitions invalidate the cached list
instead of serving another user's snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(swr): harden tiered cache flush + scope re-hydration
- localStorageProvider: flush both tiers on visibilitychange→hidden (and
pagehide) instead of beforeunload. IndexedDB writes are async and can't be
awaited on teardown; flushing while the page is still alive (hidden) gives
them time to land before unload.
- Query: reset the new scope's hydration readiness before reloadScope() (in a
layout effect), so the boot gate keeps blocking through the async IDB re-load
instead of rendering stale data from a previously-visited scope.
- CacheHydrationGate: render the brand logo while gating instead of returning
null, keeping the hand-off from the static loading screen seamless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Fills the three refactor-critical holes left in the characterization net
(LOBE-10377) — exactly the invariants LOBE-10378/10379/10382 will rewrite.
- client (streamingExecutor): waiting_for_async_tool leaves the op UNcompleted
(no switch case) and emits an undefined complete-signal status (normalize
falls through); waiting_for_human completes-for-UI but does NOT drain queue
or mark unread (parked != terminal).
- gateway (gatewayEventHandler): waiting_for_async_tool park is currently
treated as a completed + unread terminal (no pause short-circuit), and shares
the `interrupted` reconciliation branch (preserve streamed content vs DB
refetch, uiMessages SoT takes precedence).
- lifecycle (conversationLifecycle): post-persist summaryTopicTitle fires on the
CLIENT path (new-topic OR empty-title gate) and is NOT invoked on the GATEWAY
path (early return; title handled server-side).
Tests-only; characterization (locks current behavior, incl. suspected gaps with
comments). 135 tests pass across the 3 files.
Part of LOBE-10376
Home InputArea computed isAgentConfigLoading but never passed it to
DesktopChatInput, so AgentModeNotice flashed the "model unsupported"
warning during hydration. Forward isConfigLoading like every other
call site so the notice only appears after config loads.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(swr): unified tiered cache provider (localStorage + IndexedDB) with scope isolation
Route SWR persistence to a tier chosen centrally by key — IndexedDB for large
business entities (messages, topics, tasks, documents, agents), localStorage for
small list shells (recents) — instead of stuffing everything into one ~5MB
localStorage blob. Partition every tier by identity scope (`${userId}:${workspaceId}`)
so users/workspaces sharing an origin never collide, and add a boot hydration gate
so local-first data is present before the routed app mounts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(swr): centralize IndexedDB-tier keys into swrKeys registry with domain:resource naming
Introduce src/libs/swr/keys.ts as the single source of truth for SWR cache keys,
named uniformly as `<domain>:<resource>` (e.g. message:list, topic:list,
task:detail). Migrate the IndexedDB-tier domains (message, topic, agent, group,
task, document/page/notebook, brief) off scattered local consts/inline literals
onto registry factories, updating call sites, mutate matchers, and tests. The
tiered cache provider now routes by `domain:` prefix instead of ad-hoc
substrings, and matchDomain() enables refreshing a whole domain at once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(provider): use base-ui modal so delete confirm stacks above the config dialog
Closes#15836
* 💄 style(provider): split delete confirm into short title and description
* 🌐 chore(i18n): sync delete confirm title/description across all locales
* ✅ test(chat): characterization net for agent runtime run-lifecycle
Lock the CURRENT client / gateway / heterogeneous run-completion behavior
across all terminal branches BEFORE the unified run-lifecycle refactor
(LOBE-10376), so any behavioral drift is caught by tests.
- client (streamingExecutor): afterCompletion fires on error terminal;
complete-signal status=failed on error; queue-drain + markUnread skipped
on error (negative); desktop-notification gating (content && !tools)
- gateway (gatewayEventHandler): error event completes op WITHOUT markUnread
(asymmetry vs agent_runtime_end); completeOperation double-call idempotency
- hetero (heterogeneousAgentExecutor): notification + dock badge on success;
updateTopicMetadata-rejection behavior; queue-drain gating
(success / !aborted / !error); error & abort paths fire no notification/drain
- entry points: regenerate-hetero (imageList + parentOperationId +
onRegenerateComplete), continue-hetero early-return, rejectAndContinue
client dual-op, submitHeteroIntervention IPC submit + GC fallback
Tests-only; no implementation changes. 255 tests pass across the affected files.
Part of LOBE-10376
Closes LOBE-10377
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(chat): surface executor rejections in hetero completion helpers
The clean-completion `runToComplete` helper (and its sibling `runToError`)
awaited the executor with `.catch(() => {})`, swallowing any rejection. Both
paths resolve today, so this only masked future regressions: a happy/error
run that starts rejecting after some side effects would still pass — the
isDesktop=false "no notification" negative assertion is especially vulnerable
since an early rejection before the notification step trivially satisfies it.
Await the executor promise directly so a rejection fails the characterization
test instead of passing silently. 70/70 still green (both paths resolve today).
Part of LOBE-10376
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(server): dedupe replayed main-turn newStep on a cold replica
The main-agent coordinator cuts a turn purely on the adapter's `newStep` signal and minted a fresh random assistant id each time, with no DB-homed idempotency key for the turn (unlike the subagent path after #15808). On a cold serverless replica the in-memory `processedKeys` dedupe is empty, so a BatchIngester retry reprocesses the `newStep` and `openTurn` forks a second assistant — orphaning the first as a usage-only empty shell (the remote-CC "空壳" bubble).
Mirror #15808 onto the main chain: the adapter emits the turn's CC `message.id` on `stream_start{newStep}`; the reducer records it as `currentMainMessageId` and treats a same-id `newStep` as a replay (no-op); the server stamps it on `metadata.mainMessageId` and recovers it on a cold replica. Backward-compatible: a `newStep` without a message id opens a turn as before.
Regression: HeterogeneousPersistenceHandler.mainTurnRehydration.test.ts (cold-replica retry: 2 assistants + empty shell -> 1) plus 4 mainAgentCoordinator reducer unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(cli): mark topic failed when remote CC relays a terminal error on a clean exit
Claude Code relays API/rate-limit errors as an in-stream terminal `error`
event but still exits 0. The CLI derived the heteroFinish result from the
process exit code alone, so such runs reported `result: 'success'` →
`reason: 'done'` and the topic/task was wrongly marked completed instead of
failed (the error was only persisted on the message).
Track whether a terminal `error` event was pushed to the ingester and force
`result: 'error'` even on a clean exit, mirroring the desktop executor where
the stream error drives both the message error and the topic status. Also
surface the terminal error message as the finish error detail (CC relays these
on stdout, so stderr is empty in this case).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix: clear credentials on URL change; gate Edit button to http connectors
P1 (AddConnectorModal): when handleEdit detects a URL change, pass
credentials: null so the server drops the old OAuth token — a stale token
from the previous server must not be sent to the new one. The server-side
update mutation now also clears tokenExpiresAt in the same round-trip
whenever credentials are set to null.
P2 (ConnectorDetail): narrow the Edit button (and the modal mount) from
isMcpConnector to isMcpConnector && connector.mcpConnectionType === 'http'.
stdio connectors have no mcpServerUrl, so the URL-edit dialog would open
with an empty field and mislead the user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connectors): add edit/uninstall buttons for SkillDetail connectors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix: re-enable OAuth in edit mode + pre-fill bearer/header credentials
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix: resolve TypeScript errors in CustomConnectorModal edit mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix: add clientId/clientSecret to mcp.auth type to resolve TS error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix: correct description field location in editValue
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 📝 docs: add capability-gated feature checklist to ux skill
Guide designers to fulfil the reminder obligation when a selected model
or its still-loading config can't deliver a feature's required capability
(e.g. agentic tool calling): surface a soft, reactive, load-gated warning
with the remedy, rather than failing silently.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: broaden ux skill trigger to any UI work
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: simplify ux skill description
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(page-editor): isolate page copilot context from global agent/document state
Two independent bugs both rooted in the page conversation context leaning on
process-global singletons that can't express multiple tabs/documents:
- Heterogeneous agents (Claude Code / Codex) leaked into the page copilot:
`selectedAgentId` only excluded empty and chat-group ids, so navigating from
a heterogeneous agent tab made the page right panel run that external agent.
Also fall back to the page agent when the active agent is heterogeneous.
- `documentId` was lost in multi-tab scenarios because the conversation context
carried no documentId and relied on the `pageAgentRuntime` singleton, which
represents only one open document and is cleared on tab switch — causing
"PageAgent server runtime received a tool call without documentId". Inject the
editor's `pageId` straight into `context.documentId` so the send-time guard
uses a deterministic value instead of the singleton.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(page-editor): include documentId in the page conversation key
The previous fix injected `documentId` into the conversation context, but all
state isolation (messages, operations, input-loading/runtime selectors,
replaceMessages) is keyed through `messageMapKey(context)`, which dropped
`documentId` entirely for page scope. Two documents sharing the page agent thus
collapsed into one `page_<agent>_new` bucket — document B could inherit A's
copilot history or be queued behind A's running operation while tool calls now
target B.
Carry `documentId` into the page-scoped key (as subTopicId) so each open
document gets its own isolated bucket; topicless page keys avoid emitting a
literal `null` segment, and the no-document case still falls back to
`page_<agent>_new` without colliding with document-specific keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
✨ feat(chat): warn when agent mode is on but the model lacks tool calling
Show a warning above the desktop chat input when Agent mode is enabled
but the selected model does not support function/tool calling, suggesting
switching to a model with agent capability.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(topic): add queryTopics query with server-side status filter
Adds `topicModel.queryTopics({ statuses?, pageSize? })`, a lambda `queryTopics`
TRPC procedure, and `topicService.queryTopics` — filtering topics by status
server-side (e.g. to list actively-running topics across all agents without
pulling the full topic set to the client).
Removes the now-unused `getAllTopics` procedures (lambda + mobile),
`topicModel.queryAll`, and the `getAllTopics` service method.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(topic): ownership isolation tests for queryTopics; authed mobile getTopics
- queryTopics: assert it only returns the model user's topics (a status filter
must not leak another user's data) and that personal vs workspace scopes stay
isolated.
- mobile getTopics: switch from publicProcedure to the authed topicProcedure
(drops the manual userId guard + ad-hoc TopicModel construction).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Define LobeHub's four product design values — 自然 Natural / 意义感 Meaningful /
确定性 Certainty / 生长性 Growth (adapted from Ant Design's values) — in a
dedicated reference file (references/design-values.md), and keep the skill index
focused on per-aspect execution checklists, each tagged with the value it serves:
- Flow & momentum: push the user forward; success state = primary "go to result".
- States: empty / loading / error all designed; empty is a purpose-built page.
- Buttons & focus: exactly one primary button per surface.
- Lists at scale: design for 1 → 10k rows (virtual scroll / pagination / batch).
- Option visibility: pickers list all valid targets (e.g. the virtual inbox).
- Loading visuals: no antd Spin; use NeuralNetworkLoading / project loaders.
- Discoverability & growth: progressive disclosure; surface next capability in context.
- Entity lifecycle completeness: no display-only features — design full CRUD +
lifecycle, with the operation set scoped to the entity's source (official =
read-only, community = install/uninstall, custom = full CRUD).
Also: react skill points to ux for loading components, and AGENTS.md references
the ux skill for designing/reviewing user-facing flows.
* ✨ feat(topic): add bulk move topics to another assistant UI
Surface the batch-move feature in the per-agent Topics manager:
- `MoveToAgentButton`: a bulk action that opens an assistant picker
(excludes the source agent) and moves the selected topics over.
- Wire it into `BulkActionBar` next to favorite/archive/delete.
- `batchMoveTopicsToAgent` store action: calls `topicService.batchMoveTopics`,
optimistically drops moved topics from the current list, refreshes, and
switches away if the active topic was moved.
- i18n keys (en-US source + zh-CN) for the move action, picker, and toast.
Depends on the server `topic.batchMoveTopics` mutation (already on canary).
Part of LOBE-10330
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(topic): add per-topic move menu + confirm/progress move modal
Address review feedback on the move-topics UI:
- Add a "move to another assistant" item to the per-topic dropdown menu in
the left sidebar topic list (single-topic move).
- Introduce a shared MoveTopicsModal (base-ui) with a pick → confirm →
moving → done state machine: a confirmation step before the move, an
in-progress "Moving…" view that locks dismissal, and a "moved" completion
view. Both the bulk action and the per-topic menu open this modal.
- BulkActionBar's move button now opens the modal instead of a popover +
toast, so multi-select moves get the confirm + progress + done flow.
- i18n: add management.moveModal.* + actions.moveToAgent (en-US + zh-CN);
drop the now-unused management.bulk.moveSuccess toast keys.
Part of LOBE-10330
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(topic): allow moving topics to the inbox (LobeAI) assistant
The move picker sourced agents from the sidebar list, which excludes the
virtual inbox agent — so the default "LobeAI" assistant could never be
chosen as a move target (picker showed "no other assistants"). Prepend the
inbox agent to the target list (unless it is the source), mirroring
AssigneeAgentSelector. The DB-layer ownership check already accepts the
inbox agent, so moving into it is valid.
Part of LOBE-10330
* 💄 style(topic): use NeuralNetworkLoading for the move-in-progress state
Replace the antd Spin in the move modal's "moving" step with the project's
NeuralNetworkLoading, matching the product loading visual. Also document the
rule in the react skill: antd Spin is forbidden — use NeuralNetworkLoading
(or the other src/components loaders) instead.
Part of LOBE-10330
* 💄 style(topic): add "go to target assistant" action on move success
On the move modal's done step, make "Done" a secondary (weak) button and add
a primary "Go to <target>" button that navigates to the assistant the topics
were moved into, so the user can jump straight to the relocated topics.
Part of LOBE-10330
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
🐛 fix(conversation): stop tool workflow collapse showing "working" once content renders below it
When an assistant group is still generating, a workflow segment can have a real
answer segment rendered below it — most notably an errored tool block, which
splits into a folded workflow (the tools) plus a trailing answer segment (the
error text). The group-level `workflowChromeComplete` only accounts for the
promoted-final-answer path (`postToolTailPromoted`), so in these cases the
collapse kept rendering its streaming "working" header even though the model had
already moved past it and content was visible below.
Derive completeness from segment ordering: a workflow segment that has any
rendered content after it is no longer the active step. Add
`hasRenderedContentAfter` and OR it into the per-segment `workflowChromeComplete`.
Guard the shortcut with `hasPendingIntervention`: `areWorkflowToolsComplete`
ignores pending-intervention tools and the "awaiting confirmation" UI only shows
while streaming, so a segment still awaiting user confirmation must keep its
streaming chrome even with content below it.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The operation status tray maintained its OWN running token total by summing
every `turn_metadata` event's usage (`addUsageToOperationMetrics`), separate
from the per-message usage written via `recordUsage`. The two diverged badly:
in an agentic Claude Code loop the tray showed ~8M while the per-message bubbles
summed to ~2.2M.
Root cause is two computations for one number:
- `recordUsage` OVERWRITES each assistant message's usage (last turn wins when
multiple turns map to one message).
- the tray ADDED every turn's usage — and each turn's `totalTokens` includes
`cache_read_input_tokens`, so a re-read context got counted once per turn.
Make the per-message usage the single source of truth: `OpStatusTray` always
derives the total via `calculateOperationUsageMetrics(messages)` (previously
only a fallback), and the parallel `addUsageToOperationMetrics` accumulation is
removed from both the heterogeneous-agent executor and the gateway handler. The
tray now equals the sum of the bubbles and refreshes as messages do.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
On a live gateway / remote-CC stream, a subagent (Claude Code `Agent`/`Task`)
inner-tool event is tagged with `data.subagent` and belongs to an isolation
Thread, not the main bubble. The gateway path fed raw events straight into
`createGatewayEventHandler` (main-agent-only), so a subagent `tools_calling`
chunk appended the inner tool onto the MAIN assistant's `tools[]` — the tools
"leaked" into the parent bubble DURING streaming, then snapped back when the
terminal `fetchAndReplaceMessages` pulled correct DB state (where they live
under the Thread). Classic "流式时漏出来、结束后正常".
The local desktop executor already drops `data.subagent` events before
forwarding (`heterogeneousAgentExecutor`); the gateway path didn't. Drop them at
the top of the handler — one place that covers every gateway caller, and a
no-op for the local executor (which already pre-drops). DB persistence is
unaffected: the server writes subagent rows under the Thread regardless, so they
still appear — correctly under their Thread — after the terminal fetch.
Regression: a subagent-tagged `tools_calling` chunk no longer dispatches onto
the main assistant (verified red without the drop); a non-subagent chunk still
dispatches.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Single click on the tray still starts the Quick Composer capture
session, but is now debounced 250ms so a follow-up double-click can
pre-empt it. Double-click surfaces the main window via
browserManager.showMainWindow(). macOS / Windows only; Linux trays
under AppIndicator do not emit click events and remain unaffected.
* 💄 style(chat): tighten revert confirm and toast copy
Trim the file-revert Popconfirm description from a two-sentence warning
to a single line ("This can't be undone."), and switch the success toast
from full {{filePath}} to just {{fileName}} so it doesn't span the screen
for deep paths. Updated across all 18 locales.
* ♻️ refactor(chat): migrate file revert from Popconfirm to base-ui confirmModal
Per @lobehub/ui/base-ui-first convention. Drops the local confirmOpen/reverting
state and the data-force-visible CSS pin (no longer anchored to the trigger),
and lets confirmModal handle the OK button's in-flight loading.
* 🐛 fix(conversation): render mixed assistant blocks in natural order
Drop the `shouldPromoteMixedBlockContent` heuristic that relocated a
tool-bearing block's prose below its tool when the text scored as
"final-answer-like". Within one assistant message the model's text always
precedes its tool_use (tool_use ends the turn; post-tool prose lands in a
separate, tool-less block), so a mixed block's content is always a preamble
and must stay above its tool. This fixes Claude Code turns (e.g.
askUserQuestion) that rendered the tool card above its own explanatory text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(conversation): keep mixed multi-tool preamble outside the workflow fold
A mixed block's prose is a preamble, so in a multi-tool turn lift the full
text into a visible answer segment above the workflow and leave only the
tool(s) in the fold. Previously `leadingSentenceSplit` kept only the first
sentence visible and pushed the remaining prose into the WorkflowCollapse
body, which defaults to collapsed once complete — hiding most of the
explanation until the user expanded it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
# 🚀 LobeHub Release (20260614)
**Release Date:** June 14, 2026\
**Since v2.2.3:** 99 commits · 99 merged PRs · 11 contributors
> This cycle deepens cross-device collaboration — browser pairing, a
shared desktop/CLI device gateway, and edit locks that keep multiple
agents and people aligned on the same Context.
---
## ✨ Highlights
- **Browser device pairing** — Pair a browser as a device and route
agent tools to it, with rename/delete actions on the branch switcher.
(#15678, #15774)
- **Shared device gateway** — Desktop and CLI now share one
remote-device gateway RPC, so device-bound runs behave the same
everywhere. (#15780)
- **Operation status tray** — A live op-status tray sits above the chat
input, tracking operation usage and staying compact on narrow screens.
(#14737, #15736, #15735)
- **Inline file previews** — HTML files render inline and remote
read-only local files preview directly in the portal. (#15671, #15673)
- **New providers** — Added AntGroup (蚂蚁百灵), Longcat with live
model-list fetch, and new SenseNova models. (#13713, #15134, #15306)
- **Desktop tab management** — Drag-to-reorder desktop tabs, plus
restored cloud desktop builds. (#15787, #15666)
---
## 🏗️ Core Agent & Runtime
- **Heterogeneous chaining** — Stabilized main-message chaining and
unified the client hetero executor on a shared `mainAgentReducer`.
(#15783, #15762)
- **Sub-agent resilience** — Block recursive server sub-agents, keep
async sub-agent streams alive, and rehydrate sub-agent runs from DB on
cold replicas. (#15731, #15646, #15788)
- **Reasoning persistence** — Always persist assistant reasoning to the
DB so it survives reloads. (#15687, #15690)
- **Device routing** — Resolve device routing and device-tool injection
through a single execution plan. (#15669, #15683)
- **Image attachments** — Persist and deliver image attachments for
device/sandbox hetero runs. (#15685)
- **Virtual sub-agents** — Split the virtual sub-agent entry and
clarified its naming. (#15733, #15737)
---
## 🖥️ Chat & User Experience
- **Topic management** — Topic sidebar status indicators, selector topic
actions, and a `batchMoveTopics` mutation for bulk moves. (#15739,
#15744, #15793)
- **Local file portals** — Scope local file tabs by working directory
and auto-close empty local previews. (#15732, #15760)
- **Editing** — Coalesce document autosave history into 10-minute
windows and fold connector OAuth into the custom MCP form. (#15716,
#15661)
- **Skills** — Delete/remove actions on settings skill items. (#15708)
- **Polish** — Preserve message order after tool results and stop
ContentLoading from leaking raw operation i18n keys. (#15657, #15752)
---
## 🤖 Models & Providers
- **Model bank metadata** — `knowledgeCutoff` batch 2 with a metadata
skill and an always-visible tab bar, plus backfilled family/generation
data. (#15663, #15642, #15640)
- **Provider quality** — Improved DeepSeek structured output, Kimi code
thinking mode, and a model guard kept in provider grouping. (#15680,
#15725, #15681)
- **Discoverability** — Surface model-list fetch failures instead of
failing silently. (#15753)
---
## 🔒 Reliability & Security
- **Error classification** — Classify "Agent state not found" as
`StateStoreReadError`, classify untyped `Error` throws via message
patterns, and surface missing tool calls as errors. (#15778, #15767,
#15691)
- **Codex** — Parse retry time in the stated timezone and detect the
bundled Codex CLI from Codex.app on macOS. (#15758, #15759)
- **Mobile** — Stop the `pushToken.unregister` 401 storm while
preserving authenticated legacy cleanup, and gate inbox unread count by
login state. (#15719, #15723, #15724)
- **Performance** — Derive topic activity from messages and drop sitemap
generation to cut static export time. (#15726, #15702)
- **Security:** Bumped `@opentelemetry/auto-instrumentations-node`,
`@opentelemetry/sdk-node`, and `vitest`. (#14686, #14687, #15698)
---
## 🔧 Tooling & Docs
- **Agent testing** — Merged local-testing and cli-backend-testing into
a single `agent-testing` skill, with local dev env bootstrap and
post-run iteration. (#15699, #15757, #15700, #15750)
- **Docs** — Replaced Claude-specific references with generic agent
wording across skills. (#15785)
---
## 👥 Contributors
Huge thanks to **11 contributors** who shipped **99 merged PRs** this
cycle.
@hezhijie0327 · @cokeSEE1 · @R3pl4c3r · @arvinxx · @tjx666 · @Innei ·
@Rdmclin2 · @LiJian · @sudongyuer · @Neko · @cy948
Plus @lobehubbot and renovate[bot] for maintenance.
---
**Full Changelog**:
https://github.com/lobehub/lobehub/compare/v2.2.3...release/weekly-20260614
* 🐛 fix(chat): normalize reconnect startTime to epoch ms
After a DB rehydrate (quit + relaunch), an assistant message's `createdAt`
can arrive as an ISO string / Date rather than epoch ms (the message service
casts rows `as unknown` without converting). The gateway reconnect path
anchored a running operation's `startTime` to that value verbatim, so the
running-elapsed-time label computed `Date.now() - startTime` as NaN and
rendered "NaN:NaN" in the topic list.
Normalize `createdAt` to epoch ms and only set `startTime` when the result is
finite; otherwise fall back to `startOperation`'s default `Date.now()`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(chat): assert reconnect omits startTime via matcher
Avoid indexing mock.calls (TS2532/TS2493 on the untyped spy tuple); use
toHaveBeenCalledWith + expect.not.objectContaining instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(topic): scope per-agent topic search by agentId
The per-agent Topics search resolved agentId→sessionId and filtered only
by the container (sessionId/groupId). Topics created by the new agent
system carry `agentId` directly with a null sessionId, so they were never
matched — the search showed "No topics match these filters" even though
the topics list (filtered by agentId) and global search displayed them.
`queryByKeyword` now accepts an agentId-aware scope mirroring `query`'s
precedence (groupId > agentId > containerId), matching `topics.agentId`
directly while still matching the resolved sessionId for legacy
un-migrated rows. The lambda searchTopics router passes the agentId
through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(topic): align keyword search scope with the topics list
Address review on #15798:
- Drop the resolved-sessionId fallback in the agent branch. The topics list
(`query`) scopes by agentId only, so the fallback (a) surfaced un-migrated
rows the list hides and (b) leaked topics owned by another agent that shares
the same session mapping. `matchKeywordScope` now mirrors `query` exactly:
groupId > agentId > containerId (the last only for legacy/mobile string args).
- Topic inbox no longer exists, so no isInbox handling is threaded through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
On a cold serverless replica the subagent run is rebuilt from DB, but the run's
turn identity — CC's per-turn `message.id` (`currentSubagentMessageId`) — was the
one field with no DB home, so rehydration hard-set it to ''. The subagent reducer
detects in-thread turn boundaries by comparing that id, so the first event of
every cold batch satisfied `'' !== realId` → a SPURIOUS turn boundary. One CC
subagent turn then fragmented across multiple in-thread assistant rows (text on
one, tools on another), spawned empty-shell assistants (only usage, no
content/tools), and mis-anchored siblings under the same old tool.
Give the turn id a DB home: stamp it on the in-thread assistant's
`metadata.subagentMessageId` at creation (`CreateMessageIntent.subagentMessageId`
→ server interpreter), and recover it in `buildSubagentSnapshot` →
`SubagentRunSnapshot.currentSubagentMessageId` → `rehydrateSubagentRunsState`. A
continuation is then recognized as the SAME turn — no spurious boundary, no
fragmentation, no empty shells. `MessageModel.update` deep-merges metadata, so
later usage/content writes don't clobber the stored id.
Follow-up to #15788 (subagent thread rehydration): that fixed the thread-
duplication half of cold-replica recovery; this fixes the turn-boundary half.
Regression: a CC turn continued on a fresh replica now yields exactly one
in-thread assistant (verified red without the recovery).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(chat): inject device-bound project skills into the slash menu
The `/` slash menu loaded project skills via `localFileService.listProjectSkills`
(local Electron IPC) and gated on `isDesktop` alone, so a device-bound (remote)
run scanned the controlling machine instead of the device — and the device's
`.claude/skills` / `.agents/skills` never appeared.
Route through the device-aware `projectSkillService` with the resolved
`remoteDeviceId` and gate on `(isDesktop || !!remoteDeviceId)`, mirroring the
WorkingSidebar's `SkillsGroup`. The SWR key shape matches `useProjectSkills` so
the two share one fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(chat): extract shared useFetchProjectSkills hook
Both the `/` slash menu and the SkillsList UI hook duplicated the same
project-skills SWR call (key, fetcher, options). Pull it into a single
`useFetchProjectSkills(workingDirectory, deviceId)` hook so the transport choice
and SWR key live in one place and the two callers dedupe one fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(chat): revalidate remote project skills on focus
Remote skills live on a device this client can't watch for filesystem changes,
so refetch them on window focus to pick up edits made on the device. The local
IPC path keeps revalidateOnFocus off — the desktop already sees its own
filesystem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(chat): resolve effective execution target before picking device id
The slash menu read the raw stored `executionTarget`, so a hetero agent saved as
desktop "This device" (`local` + boundDeviceId) opened on web — where
`resolveExecutionTarget` coerces it to `device` — kept `remoteDeviceId`
undefined and left the menu without project skills, even though the
WorkingSidebar (which resolves the effective target) lists them for the same
agent. Resolve the effective target the same way and treat it as remote only
when it lands on `device` with a bound device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Expose TopicModel.batchMoveToAgent through a new topic.batchMoveTopics
lambda mutation (topic:update scoped permission, input { topicIds,
targetAgentId }) and add the matching topicService.batchMoveTopics client
wrapper.
Depends on the database layer (TopicModel.batchMoveToAgent).
Part of LOBE-10330
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
✨ feat(database): add batchMoveToAgent to TopicModel
Add a transactional TopicModel.batchMoveToAgent(topicIds, targetAgentId)
that reassigns topics to another agent purely via the agentId foreign key.
Both topics.agentId and messages.agentId are updated together (topic lists
query by topics.agentId and message queries filter by messages.agentId),
and sessionId is cleared on both tables so rows fully detach from the
source agent's legacy session. Scoped by ownership to prevent cross-user
moves.
Part of LOBE-10330
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(server): rehydrate subagent runs from DB on cold replica
Server-side hetero persistence kept per-operation state in a module-level
map. On a cold serverless replica (or any cross-replica batch), the main
agent state is rebuilt from DB but `MainAgentRunState.subagents` was seeded
empty. A continuing subagent event then hit the `!existing` branch of
`ensureRun` and forked a brand-new isolation thread for a parentToolCallId
that already had one — producing piles of generic "Subagent" threads that
were never attached to the right thread. Desktop never hit this (one
long-lived run-state closure).
Rebuild `state.main.subagents` from DB the same way the main half is
rehydrated: add `rehydrateSubagentRunsState` to @lobechat/heterogeneous-agents
and call a new `refreshSubagentRunsFromDb` each ingest. Only runs MISSING
from memory are rehydrated (warm accumulators win); finalized (Active)
threads are excluded so completed spawns are never resurrected.
Sibling of #15783 (main message chaining) — same root cause, subagent half.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(server): scope subagent rehydration to operation + de-dupe inner tools
Two follow-up fixes on the cold-replica subagent rehydration:
- P1: de-dupe inner tool creation against the run-lifetime tool set, not just
the per-turn `persistedIds`. Per-turn state is reset on every turn boundary
and starts empty after a rehydration, so a replayed / continued tools_calling
on a cold replica minted a SECOND tool message for an id the run already
wrote. `lifetimeToolCallIds` survives boundaries and is restored from DB, so
it is the durable de-dupe key. Mirrors the main-agent retry protection.
- P2: scope `refreshSubagentRunsFromDb` to the current operation. Topics are
reused across turns; a prior crashed/cancelled run can leave a subagent
thread stuck `Processing`. Rehydrating purely by topic+status would merge
that unrelated thread into the new operation's reducer state and finalize it
on the new run's terminal drain. Stamp `operationId` on the subagent thread
metadata at creation and filter rehydration by it.
Adds regression cases for both (each verified to fail without its fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat: support drag-to-reorder for desktop tabs
Make the Electron titlebar tabs draggable horizontally to reorder them,
like Chrome tab dragging. Wires the existing `reorderTabs` store action
to a @dnd-kit sortable context.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix: preserve scroll position when reordering background tabs
The active-tab auto-scroll effect depends on `tabs`, so reordering
retriggered it and jumped the viewport back to the active tab. Guard it
with a ref so it only scrolls when the active tab id actually changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(device): share remote-device gateway RPC between desktop and CLI
Extract the desktop's remote-device gateway RPC surface into a shared
`@lobechat/device-control` package and wire it into the CLI so `lh connect`
serves the same git / workspace / file device RPCs as the desktop app.
- local-file-shell: relocate all git operations (branches, working-tree
patches, branch diff, checkout/rename/delete/pull/push/revert) from the
desktop GitCtr into the shared package as pure functions
- device-control (new): the `executeDeviceRpc` dispatch + workspace scan +
portable file-preview / file-index defaults, with platform hooks injected
- desktop: GitCtr / WorkspaceCtr / GatewayConnectionCtr become thin wrappers
delegating to the shared package (local IPC path unchanged)
- cli: handle `rpc_request` over the gateway via the shared dispatcher
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(device): cover git branch ops and device-control portable defaults
- local-file-shell: real-git integration tests for branch checkout / rename /
delete (+ validation), working-tree files & patches, revert, branch-diff with
no remote, and push / pull / ahead-behind against a bare origin
- device-control: defaultGetLocalFilePreview (text / image / accept filter /
workspace containment / missing file) and defaultGetProjectFileIndex (git
ls-files path + glob fallback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(device): preserve directory entries in the glob project-file index
The CLI `getProjectFileIndex` glob fallback used `globLocalFiles`, which returns
only non-hidden file paths and no directory entries — so the Files tree builder
flattened nested files to the root and dropped dot-directories.
Walk with fast-glob (`dot: true`) and synthesize directory entries via the same
`collectProjectDirectories` path the git branch uses, so nesting and dot-dirs
(e.g. `.agents`) render correctly. Extracted a shared `buildEntries` helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
`coordinator.loadAgentState(operationId)` returning null throws a raw
`Error("Agent state not found for operation …")`, which (after the refine fix)
otherwise lands as a bare 500. It is a state-store READ failure, so route it to
StateStoreReadError alongside the caller-gone abort.
Because losing an operation's state is a genuine system fault (not benign
client abandonment), promote StateStoreReadError to countAsFailure: true /
severity: error. `ERR caller gone` now counts too — accepted trade-off, both
are system-side read failures worth tracking.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(hetero): add shared mainAgentCoordinator reducer
Pure, transactional main-agent run reducer mirroring subagentCoordinator.
Owns the asst→tool→asst chain rule (lastToolMsgIdEver) as the single source
of truth so client and server can converge on one processing flow. Not yet
wired into either interpreter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(chat): drive client hetero executor via shared mainAgentReducer
Replace the renderer's hand-written main-agent event state machine with the
shared reduceMainAgent + an applyIntent interpreter (main + delegated subagent
intents). The executor keeps its shell (persistQueue/IPC ordering, optimistic
intervention UI, op usage-metrics tray, notifications, resume fallback) and
still forwards raw events to the gateway handler for live UI; durable DB writes
now flow through the reducer's intents, so the asst→tool→asst parent chain
(incl. the lastToolMsgIdEver toolless-step rescue) is a single shared source of
truth with the server.
Tool/assistant message ids are now pre-allocated by the reducer (matching the
subagent path); updated the executor tests to honor caller-provided ids and
assert against captured ids instead of mock-minted ones.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs(chat): clarify why main-scope streamContent intent is a no-op
It's intentional, not dead code: main live token UI is driven by the raw
stream_chunk forward to the gateway handler; the intent only drives the
subagent thread bucket (whose events are dropped before that forward).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(chat): close two hetero executor races from reducer refactor
Two review-found bugs introduced by moving main-agent state into the queued
reduceAndApplyMain:
1. retryWithoutResume's hasStreamedState() read mainState, which is now only
updated inside the queued reduce — so a recoverable resume error landing
after partial output was queued (but before the queue drained) could start a
second run and duplicate/interleave messages. Restore the old synchronous
guarantee with a `sawStreamedEvent` flag set the moment a stream_chunk /
tool_result arrives, before queueing.
2. A transient createMessage failure on a step-boundary assistant was
best-effort (logged, not rethrown), so reduceAndApplyMain still committed
currentAssistantId to a row that was never created — every later
content/tool/result write then targeted a missing assistant and was lost.
Rethrow so the commit is skipped and currentAssistantId stays valid, mirroring
the subagent createMessage path.
Both guarded by regression tests that fail without the fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Hover a branch row in the branch switcher to rename or delete it. Wires
new renameGitBranch / deleteGitBranch operations through both transports
(Electron IPC for the local machine, device.* TRPC RPCs for remote/web),
mirroring the existing checkoutGitBranch / revertGitFile stack.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The generating status phrase was picked once per operation and stayed
frozen for the whole run. Rotate it like a carousel — advancing to the
next phrase every 4s with a subtle fade — so a long-running task feels
alive instead of stuck on one line.
- add pickRotatingStatusPhrase: seed keeps the starting phrase stable
per operation, step advances the carousel; reuses the existing 1s
elapsed ticker so no extra timer is needed
- fade/slide the phrase on each switch via a keyed wrapper span (keeps
the shiny-text shimmer animation intact)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(model-runtime): classify untyped Error throws via message patterns
`refineErrorCode` only re-derived a specific code when the incoming errorType
was `ProviderBizError`, so raw `Error` throws — which `formatErrorForState`
wraps as `InternalServerError` (HTTP 500) — never reached `matchErrorPattern`.
Persistence-layer (`Failed query: …`) and state-store drops therefore landed
as bare, un-classified 500s instead of `DatabasePersistError` etc.
Add the two un-typed fallback wrappers (`InternalServerError`, `AgentRuntimeError`)
to `REFINABLE_CODES` so their message runs through the pattern registry before
falling back. The existing `Failed query:` pattern already classifies these;
this just lets it run again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(model-runtime): classify Upstash readonly-upgrade & dropped-caller drops
Add `READONLY Writes are temporarily rejected` and `ERR caller gone` to the
StateStorePersistError pattern block — both are Redis/Upstash state-store
failures that otherwise fall through to a bare 500. They describe the
connection/server condition rather than a specific command, so there is no
read-vs-write signal to split on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(model-runtime): split caller-gone state-store reads into StateStoreReadError
`ERR caller gone` is an Upstash reply when an in-flight blocking READ
(XREAD on the agent event stream, BLPOP on a tool result) is aborted because
the originating caller disconnected — a benign client abandonment tied to the
request lifecycle, not a write/persist fault. Bucketing it under
StateStorePersistError mislabelled it as a harness failure (attribution:
harness, countAsFailure: true).
Add a dedicated StateStoreReadError (E7007, attribution: system, severity:
warning, countAsFailure: false) and route `ERR caller gone` to it. The
write-side rejection `READONLY Writes are temporarily rejected` stays under
StateStorePersistError.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(model-runtime): scope HTTP-status fallback to provider catch-alls
Opening the un-typed wrappers (InternalServerError / AgentRuntimeError) to the
full refine path also let them hit the leadingStatusFromMessage /
codeFromHttpStatus fallback. A harness/DB/Redis throw like `Error('429 …')` or
`Error('500 …')` with no registered pattern would then be recast as
RateLimitExceeded / ProviderServiceUnavailable — provider retry/failure
semantics on a harness error.
Split the sets: PATTERN_REFINABLE_CODES (message matching) stays open to the
wrappers; STATUS_REFINABLE_CODES (the coarse HTTP-status bucket) is limited to
ProviderBizError, where a leading status is a real upstream signal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Internal/bookkeeping operation types (createToolMessage, executeToolCall,
pluginApi, builtinTool*, callLLM, searchWorkflow, ...) have no `operation.*`
locale key, so ContentLoading fell back to rendering the raw key
(e.g. `operation.toolCalling...`).
Extract OpStatusTray's operation→activity mapping into a shared
`resolveOperationActivity` helper and reuse it in ContentLoading: mappable
ops show the localized `opStatusTray.status.*` phase label, container ops
keep their dedicated copy, and unmappable ones fall back to the dot loader.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(desktop): detect bundled Codex CLI from Codex.app on macOS
OpenAI's Codex desktop app bundles the real codex CLI inside Codex.app
(Contents/Resources/codex) but never symlinks it onto PATH. A user with
only the desktop app installed failed PATH-based detection, so codex was
never spawned and the chat silently produced no reply.
Add a well-known install-location fallback inside detectHeterogeneousCliCommand
(tried after the PATH lookup, so a user's own install still wins), covering
both /Applications and ~/Applications. The fallback runs at detection time,
not module load, so it touches no node:os named exports on import. Feed the
detector-resolved absolute path through to spawn so a bare `codex` doesn't
ENOENT under spawn's leaner env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(desktop): carry login-shell PATH into CLI spawn env
When the detector resolved a bare command via the login-shell PATH, only
the absolute shim path was kept; the PATH used for resolution was dropped.
spawn() then built its env from the leaner Finder-inherited PATH, so an
absolute shim with `#!/usr/bin/env node` still failed with
`env: node: No such file or directory` even though preflight succeeded
(npm/Homebrew/mise installs launched from Finder on macOS).
Surface the resolved PATH through ToolStatus.resolvedPathEnv, stash it on
the session, and merge it into spawnEnv (session.env still wins). Only set
when resolution fell back to the login-shell PATH, so the common on-PATH
case is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(oidc): add interaction details endpoint
* ✨ feat(auth-spa): scaffold standalone auth SPA shell and build pipeline
* 🐛 fix(auth-spa): address review findings in AuthShell copies
* ✨ feat(auth-spa): add spa-auth html route handler
* ♻️ refactor(auth-spa): migrate simple auth pages into auth SPA
* 🔒 fix(auth-spa): validate locale segment in spa-auth route
* ♻️ refactor(auth-spa): move verify-im route to main SPA
* 🔒 fix(auth-spa): sanitize callbackUrl, fix signup form wiring, add router error element
* ♻️ refactor(auth-spa): migrate oauth pages into auth SPA
* 🐛 fix(auth-spa): address oauth migration review findings
* ♻️ refactor(auth): route auth pages to standalone SPA and drop Next auth tree
* 🔒 fix(auth): validate locale before middleware rewrite
* 🔥 chore(auth-spa): drop unused messenger i18n namespace from auth shell
* ⚡️ perf(build): share one react vendor bundle across web/mobile/auth SPA builds
Build react core (react, react-dom, react-dom/client, react/jsx-runtime)
once as a self-contained ESM bundle under /_spa/vendor-shared, then mark
those specifiers external in every SPA build and map them via rolldown
output.paths to the same hashed URLs, so the auth page warms the main
app's react cache. react-router-dom stays per-build: apps use ~19K of it
after tree shaking while a shared bundle must export all 252K.
Also split auth i18n namespaces into per-locale chunks, keep locale
runtime helpers out of the default locale chunk, and group packages/const
into app-const so vendor-ai-runtime no longer captures it.
* ♻️ refactor(spa): extract shared SPA html serving helpers
Both the main SPA and auth SPA route handlers duplicated the Vite dev
asset rewriting, analytics config assembly and html template rendering.
Move them into src/server/spaHtml.ts; the desktop umami block becomes an
opt-in flag only the main SPA enables.
* 🐛 fix(auth-spa): bundle default locale resources and disable i18n suspense to fix signin mount loop
* ✨ feat(auth-spa): wrap auth shell with BusinessAuthProvider slot
* 👷 build(spa): support custom vite dev origin and mark SPA entries side-effectful
* 🔥 chore: drop dead /welcome entry from nextjsOnlyRoutes
* 🐛 fix(auth-spa): forward referral to signup and fix error boundary dark-mode contrast
* ♻️ refactor(spa): lift NextThemeProvider above RouterProvider so route error boundaries are theme-aware
* update
Follow-up to #15719 addressing a Codex P2 review note.
After #15719, legacy v1.0.7 clients that only send `deviceId` were
silent-OKed unconditionally. But `publicProcedure` still receives
`ctx.userId` from `createLambdaContext` — and in the *active*
sign-out path (the user is still authenticated when logout fires)
that userId is valid. Skipping the delete in that case orphans the
existing `(userId, deviceId)` row, so `PushChannel.deliver` keeps
fanning notifications out to a signed-out device. Expo's
`DeviceNotRegistered` receipt only fires on uninstall, not on
logout, so the cron worker doesn't catch this either.
Fix: add a Path B fallback — when `ctx.userId` is available, run
the original `(userId, deviceId)` delete. Path A (expoToken pair)
still wins when present; Path C (silent OK) is now reserved for
the case the original PR was actually targeting: a v1.0.7 client
whose session is already gone, which is the source of the 401
storm.
Path matrix:
expoToken present → Path A: precise delete by (expoToken, deviceId)
no expoToken, ctx.userId present → Path B: legacy (userId, deviceId) delete
no expoToken, no session → Path C: silent OK, cron cleans up
Tests added:
- legacy + valid session → falls back to (userId, deviceId)
- legacy + no session → silent OK
- expoToken always takes precedence over userId fallback
Symptom: app.lobehub.com production logs show ~50+ TRPCError
UNAUTHORIZED traces per second on /trpc/mobile/pushToken.unregister,
starting from the v1.0.7 mobile release. Only `unregister` is hit
— `register` never appears in logs.
Root cause: the v1.0.7 client calls unregister *during* sign-out,
after the session is already invalid in practice (expired OIDC
token / cleared cookie). With authedProcedure gating, every logout
turns into a 401 that the client mistakes for an auth-expired
event and retries → a storm. Inside the client this also creates
a logout → 401 → authExpired.redirect → logout recursion.
Fix: change `unregister` to publicProcedure and authorize by the
(deviceId, expoToken) pair the client received at registration —
holding both is proof of ownership of that row, same trust model
as APNs/FCM unregister. Legacy v1.0.7 clients that only send
deviceId get a silent 200; the stale row is cleaned up by the
existing `process-push-receipts` worker via Expo's
DeviceNotRegistered receipts.
Returning 200 to those legacy calls also breaks the client-side
recursion at the source — the in-the-wild v1.0.7 fleet stops 401
flooding the moment this ships, before users update.
Tests:
- Router (mocked): expoToken path deletes by (expoToken, deviceId);
no-expoToken path silently succeeds; unauthenticated caller
succeeds; empty-string fields rejected.
- Model (integration): only the row matching both fields is
removed; mismatched expoToken is preserved (defense against
callers who only guess deviceId).
Fixes LOBE-10174
* ✨ feat(document): coalesce autosave history versions into 10-minute windows
* ✨ feat(document): break autosave history window on new page load session
* ✨ feat(conversation): add op status tray above chat input
Show elapsed time, total tokens, and total cost while an AI-runtime
operation is running in the current conversation. Lives in the floating
overlay above the chat input alongside QueueTray and TodoProgress,
attaches flush to the input panel below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 🐛 fix(conversation): read top-level message.usage in op status tray
Token totals stayed at 0 during regular agent runs because the standard
agent path writes usage to `message.usage` (top-level) while the
heterogeneous executor writes `metadata.usage`. Read both. Also drop the
fragile createdAt window — assistant messages can be created before the
AI_RUNTIME op's startTime, which excluded otherwise-valid rows — and
aggregate across the whole conversation instead.
UI: a little more padding, a pulsing dot to mark the running state, a
tokens label, and a divider between tokens and cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ✨ feat(conversation): streaming phase, ping dot, and richer metrics in op status tray
- Left side now shows the current streaming phase (thinking / calling tools /
searching / compressing / generating) derived from the most recent running
sub-operation; server runtimes surface no sub-ops on the client and fall
back to 'generating'.
- Pulse dot upgraded to an expanding ping ring animation.
- Zero-valued metrics are hidden entirely (no more '0 tokens / $0').
- Long-running tasks additionally surface turns and tool-call counts next to
tokens and total cost.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 style(conversation): polish op status tray display
* 💄 style(conversation): unify op status tray glyph to a single hue
The activity glyph mixed purple and cyan accents into the primary color;
all layers now derive from colorPrimary alone (opacity-only variation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 style(conversation): strip glyph halo fill and drop-shadow
The halo's tinted fill plus the drop-shadow rendered as a muddy disc
behind the glyph (worst in light theme). Reduce to a breathing core dot
plus a single rotating dashed orbit, primary hue only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 style(conversation): drop dollar prefix and code font in op status tray
The dollar icon already conveys currency, and the code font made the
numbers feel out of place next to the body text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(conversation): show per-message cost next to the token chip
Renders usage.cost beside the token count in the assistant message
footer; hidden in credit mode (credits already express cost) and when
the value is zero/absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 style(conversation): hide per-message cost below $0.20
Cheap messages don't need a cost callout — the chip only surfaces once
the cost is large enough to matter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(conversation): anchor reconnected op timer to real run start, surface steps
- Page-refresh reconnect recreated the gateway operation with
startTime=Date.now(), resetting the tray timer to 00:00 mid-run.
Anchor it to the assistant message's createdAt instead.
- Mirror the server's authoritative stepIndex onto op.metadata.stepCount
at every step_start event, so the steps metric shows for real
server-side runs (and survives reconnects).
- Drop the tool-call count metric from the tray.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✅ test(conversation): stub updateOperationMetadata in gateway event handler mock store
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ⚡️ perf(build): remove sitemap generation to cut static export time
The sitemap accounted for 772 of 827 prerendered pages, each fetching
marketplace data at build time. Static generation drops from 28.2s to
0.3s and total next build from ~59s to ~32s.
* Redirect legacy sitemap URLs to the landing site
* Redirect sitemap index to landing sitemap
* ✨ feat: add delete/uninstall actions to settings/skill items
- LobehubSkillItem: show compact `...` dropdown in list mode for connected items with Disconnect action (revokes OAuth)
- KlavisSkillItem: show compact `...` dropdown in list mode for connected/pending servers with Remove action (true delete via removeKlavisServer)
- ConnectorDetail: add Delete button for custom (mcp) connectors; calls deleteConnector + notifies parent via onDelete
- SkillDetail / Page: thread onDelete callback so selecting null after deletion triggers auto-select of next item
- Locales: add tools.klavis.remove / removeConfirm.title / removeConfirm.desc in en-US, zh-CN, and default source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(skill): gate Klavis remove by canEdit and clear selected after removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(skill): show dropdown for all Klavis/Lobehub items in list mode
Previously, the ... button was gated behind `server` (Klavis) and
`isConnected` (LobehubSkill), so disconnected/never-connected items
showed no actions. Remove those guards so the dropdown always renders
in list mode. handleRemove/handleDisconnect now skip the server call
when no server instance exists and instead clear the selected item.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(skill): move delete/uninstall actions from list dropdown to detail panel
- Remove heavy ... dropdown from KlavisSkillItem / LobehubSkillItem list items
- Add danger Uninstall button to builtin-skill detail header (matches ConnectorDetail style)
- Add slim action bar with Uninstall to agent-skill detail panel
- All actions respect canEdit / canCreate permissions with confirmModal gating
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix: activator tool discovery for cloud-sandbox and local-system
- P0: Explicitly inject LocalSystemManifest when device gateway is configured
(discoverable: isDesktop is always false on server, so it never enters
the discovery loop. The explicit injection mirrors the canUseDevice guard.)
- P1: Skip CloudSandboxManifest when runtimeMode is not 'cloud'
(resolveRuntimeMode unifies executionTarget='sandbox' and legacy
chatConfig.runtimeEnv.runtimeMode paths, so agents with sandbox
disabled correctly exclude the cloud-sandbox tool.)
Both fixes operate at the manifest-map build stage, consistently affecting
all downstream consumers (activator discovery, availableTools, etc.)
* 🐛 fix: remove cloud-sandbox manifest when runtime is not sandbox
The initial manifest seed via getEnabledPluginManifests includes
defaultToolIds (which contains lobe-cloud-sandbox), so the manifest
was already in toolManifestMap before the allowedBuiltinTools loop's
continue guard. This made lobe-cloud-sandbox activatable even when
sandbox was disabled.
Add a delete right after resolveRuntimeMode to cover both the
manifestMap seed and the allowedBuiltinTools loop in one place.
Co-authored-by: chatgpt-codex-connector[bot]
* ♻️ refactor: replace Segmented tabs with SearchBar in ProfileEditor tool dropdown
- PopoverContent: replace Segmented with SearchBar + internal client-side filtering (same pattern as ChatInput ActionBar)
- AgentTool: remove ~270 lines of duplicated installedTabItems useMemo; pass unified items
- AgentTool: add auto-cleanup for stale plugin identifiers in agent config
* 🐛 fix(agent): persist file attachments in hetero early-exit user message
The hetero-agent early exit in execAgent created the user message without
the `files` relation, so attachments sent from the SPA gateway path
(executionTarget=device / sandbox) were never linked via messagesFiles and
disappeared once the optimistic client message was replaced by the server
snapshot. Attach the deduped `fileIds` the same way sendMessageInServer
does on the local-mode path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): deliver image attachments to device/sandbox hetero runs
Persisting the messagesFiles relation fixed display, but the dispatched
CLI still never saw the image — local mode feeds the persisted imageList
into sendPrompt for vision, while the device/sandbox dispatch protocols
(agent_run_request / sandbox runner) only carried a text prompt.
- resolve attached images into signed URLs in the hetero early exit
(metadata-only, non-fatal) and carry them through heteroParams
- add imageList to the agent_run_request wire type and dispatchAgentRun
params (gateway client + server service)
- extract buildHeteroExecStdinPayload into @lobechat/heterogeneous-agents
so the three dispatch sites (desktop spawnLhHeteroExec, lh connect
daemon, server sandbox runner) build the same content-block payload:
systemContext, prompt, then image blocks
- lh hetero exec already coerces image blocks via coerceJsonPrompt and
normalizeImage (url → base64 for Claude Code, materialized path for
Codex), so no CLI consumer changes are needed
openclaw/hermes (runHeteroTask) keep text-only prompts — their dispatch
goes through a separate one-shot tool protocol.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(heterogeneous-agents): move exec stdin wire contract to a pure /protocol entry
The server sandbox runner imported `buildHeteroExecStdinPayload` through the
`/spawn` barrel, which (with no `sideEffects` hint) bundles the whole spawn
machinery into the Next.js server chunk. Its `process.cwd()`-rooted dynamic
fs calls then make Vercel's output file tracing glob the entire repo source
tree into every serverless function (+~69 MB each), pushing the 4 largest
functions past the 250 MB uncompressed limit and failing the deployment.
Split the dispatch wire contract (stdin payload builder + content-block
types) into a new pure, isomorphic `/protocol` export and point all three
dispatch sites (server sandbox runner, desktop main, `lh connect` daemon) at
it. `/spawn` re-exports the moved symbols so executor-side callers are
unaffected. Also declare `sideEffects: false` for the package.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(agentDocument): listDocuments returns templateId and derived fields
* fix(agentDocument): useFetchAgentDocuments use listDocuments instead of getDocuments
* fix(agentDocument): derive AgentDocumentItem from listDocuments return type
* fix(agentDocument): export AgentDocumentListItem type
* 🐛 fix(agentDocument): align list projections and consumers after rebase onto canary
- listDocumentsForTopic now returns the same projection as listDocuments
(derived fields + templateId), so the tRPC union no longer collapses
the inferred client type to the old 8-field shape
- add description/updatedAt to both projections for sidebar consumers
- AgentDocumentsGroup switches getDocuments -> listDocuments (it already
shared the documentsList SWR key)
- makePendingDocument trimmed to the lean list item shape
- update useFetchAgentDocuments test to the listDocuments behavior
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(agentDocument): migrate agentDocumentSkills sync to slim listDocuments
The tool store's skill registry sync shared agentDocumentSWRKeys.documentsList
with the working sidebar and the new useFetchAgentDocuments hook, but still
fetched the full getDocuments payload. Sharing one SWR key across different
payload shapes made the cached result order-dependent: whichever consumer
mounted first decided whether the cache held the heavy full documents or the
slim list items. Migrate the skills sync to listDocuments, whose projection
covers every field mapDocsToSkills reads.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ♻️ refactor(agent): single-track device-tool injection via execution plan
P3 follow-up to #15669 — downstream layers now consume the resolved
ExecutionPlan instead of re-deriving device capability:
- ExecutionPlan carries the effective `target`; persisted into
state.metadata.executionPlan via createOperation
- call_llm executor gates buildStepToolDelta's activeDeviceId signal on
the plan (none/sandbox can never re-inject local-system mid-run)
- AgentToolsEngine consumes the plan's target; redundant rule-level
canUseDevice checks removed (physical manifest walls remain)
- builtin agent runtime config can now override agencyConfig
(web-onboarding pins executionTarget=none)
- hetero desktop 'local' selection persists this desktop's deviceId so
opening the agent from web dispatches to the same machine via gateway
- 'local' vs 'device' stay distinct user choices even for the same
machine: gateway dispatch streams progress to all clients (mobile),
IPC is faster but desktop-session-only — guarded by a regression test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(agent): enforce device access policy on hetero dispatch
resolveDeviceAccessPolicy now runs BEFORE the hetero early exit and feeds
canUseDevice into the hetero execution plan: a denied sender (external
bot user) degrades local/device-bound CLI hetero runs to the cloud
sandbox instead of dispatching to the owner's machine, and requestedDeviceId
cannot bypass the policy. Remote hetero agents (openclaw/hermes) are
device-only with no sandbox fallback, so denied senders are refused
outright.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 style(agent): fix interface field order in RuntimeSelectionContext
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(agent-runtime): always persist assistant reasoning to DB
PR #13494 gated message reasoning persistence behind preserveThinking
(agent chatConfig + model extendParams / qwen|zhipu fallback). That gate
is only meant to control whether reasoning is replayed into the next LLM
payload — applying it to the DB write dropped thinking content for every
non-qwen/zhipu reasoning model in server-side agent mode: reasoning
streamed live via stream_end but vanished after refresh.
Restore unconditional reasoning persistence in messageModel.update and
keep the preserveThinking gate only for state.messages payload replay.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 style(i18n): localize callSubAgent tool labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✅ test(model-runtime): align tool-calling fallback tests with new return shape
#15680 changed generateObject's tool-calling fallback to return the parsed
schema object (same shape as the json_schema path) instead of an array of
tool calls, and reworked its error handling, but left the pre-existing
"tool calling fallback" block in index.test.ts asserting the old behavior,
breaking CI on canary:
- result is now the parsed object, not [{ name, arguments }]
- the no-tool-call path returns undefined via debug log without console.error
- the parse-failure path logs the single matched tool call, not the array
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(model-runtime): surface missing tool call in generateObject fallback as error
tool_choice forces the structured-output function, so a response without a
tool call means the provider misbehaved. #15680 routed this branch to a
debug-namespace log that is invisible in production, leaving callers with
an unexplained undefined. Log it via console.error with the response
message as context, matching the parse-failure branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat: add browser device pairing flow to /settings/devices
- Add "Via Browser" tab to ConnectDeviceModal with pairing code display and input
- Add "Register this browser as a device" callout card above DeviceList
- Support ?pair=<code> URL param to auto-open browser pairing modal with pre-filled code
- Improve DeviceList empty state with method cards (Desktop + CLI)
- Ship en-US and zh-CN i18n keys for all new browser/sync strings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🔨 fix(devices): fix lint warnings — import sort order and empty catch block
* fix(devices): add pair API route and invalidate device list cache
- Create /api/devices/pair POST handler that authenticates the user via
Better Auth session, validates the code against the user's registered
devices via DeviceModel.findByDeviceId, and returns JSON.
- Replace the setListKey/key-prop re-mount trick with
lambdaQuery.useUtils().device.listDevices.invalidate() so the tRPC
React Query cache is properly busted after a successful pair (fixes
staleTime: 30s preventing the new device from appearing).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ♻️ refactor(devices): drop browser pairing, fix modal close, redesign UI
- Remove the "Via Browser" pairing flow entirely: browser tab in
ConnectDeviceModal, the "register this browser" callout card, the
?pair=<code> deep-link, and the /api/devices/pair stub route. Only the
real Desktop and CLI connection methods remain.
- Fix the modal that couldn't be closed: @lobehub/ui Modal closes via
onCancel (antd), not onClose — the X button was a no-op.
- Redesign the connect modal (segmented tabs, numbered steps, command
blocks with copy, security footer) and the empty state (onboarding
hero with Desktop/CLI options + capability cards).
- Clean up browser/sync i18n keys; add capabilities + footer keys for
en-US and zh-CN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 fix(devices): apply card radius — cssVar.borderRadius already has unit
The radius tokens (cssVar.borderRadius / borderRadiusLG) already include
their unit, so the trailing `px` produced `var(--…)px`, which browsers
drop — leaving the cards with sharp corners. Drop the `px` so the cards
pick up the same rounded radius as the appearance settings FormGroup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- add resolveExecutionPlan as THE device decision (none/sandbox never
route to a device; offline bindings stay unrouted; single-online-device
auto-activation only for device-capable targets)
- fix executionTarget=none being bypassed by single-device auto-activation
(background runs executed device tools despite 无设备)
- stop exposing the remote-device proxy in none/sandbox sessions
- converge native execAgent, hetero dispatch fork and client
selectRuntimeType onto the shared resolution
- drop the legacy per-platform chatConfig.runtimeEnv.runtimeMode fallback
entirely (no migration: unset targets resolve to platform defaults)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- actions/checkout@v4 -> @v6 in issue-auto-comments.yml
(last remaining @v4 usage; all other 48 uses are already @v6)
- actions/github-script@v7 -> @v8 in release-desktop-canary.yml
(last remaining @v7 usage; all other 4 uses are already @v8)
Co-authored-by: 章岚 <zhanglan@datagrand.com>
* ✨ feat(model-bank): backfill knowledgeCutoff batch 2 and restore lost Anthropic values
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 📝 docs(skills): add model-bank-metadata skill for cutoff/family backfill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(model-bank): Claude Fable 5 belongs to the claude-mythos family
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 style(desktop): always surface the tab bar by creating a tab on first navigation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ♻️ refactor(model-bank): family is the product lineage (claude-opus/sonnet/haiku), not the brand
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(agent): backfill activeAgentId before paint on tab/route switches
Tab switches are plain route navigations, so leaving an agent page cleared
activeAgentId via a passive useUnmount and the next page re-set it in a
passive useEffect — the first painted frame always had no active id, flashing
a skeleton even when agentMap already cached the config. Move both the
backfill and the unmount clear to layout effects: removed-tree layout
cleanups run before new-tree layout effects in one commit, so the clear can
never wipe a freshly synced id and the id is in place before paint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(agent): surface agent config fetch errors with a retry action
isAgentConfigLoading only knows "no data yet", so a failed fetch (e.g. a 401
that SWR deliberately does not retry, with no focus revalidation inside a
single Electron window) left the agent page on a skeleton forever — only a
manual reload recovered. Record per-agent fetch errors in
agentConfigErrorMap (set by onError, cleared on data / retry), expose
currentAgentConfigError / isAgentConfigError selectors, add a
retryAgentConfigFetch action that revalidates the agent's SWR entries, and
show an error alert with a retry button above the main chat input while the
config is still missing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(ci): sync model metadata test expectations
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(connector): support API key / custom header / OAuth auth in custom connector
Make the connector backend a full replacement for the legacy custom-MCP plugin form:
- connector create/update now accept bearer/apikey/header credentials (encrypted at rest);
oauth2 stays callback-only
- map apikey → bearer auth and header → request headers in both the sync path
(syncTools + callTool) and the agent-runtime manifest path
- pass custom HTTP headers through to the MCP client
- AddConnectorModal becomes a rich form: MCP type (HTTP/STDIO), auth type
(None / API Key / Custom Headers / OAuth), reusing the plugin form inputs;
OAuth keeps the existing popup authorize flow, others create + sync directly
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(connector): fold OAuth into the PluginDevModal MCP form
Pivot the custom-MCP entry to reuse the rich PluginDevModal / MCPManifestForm
instead of a bespoke connector modal, and add OAuth as an auth type inside it:
- MCPManifestForm: gated `enableOAuth` adds an "OAuth" auth type with
Client ID / Secret (optional) + redirect-URI hint. Only the custom-connector
entry enables it, so plain custom-plugin DevModal callers (editing plugins,
agent tools, …) are unaffected.
- DevModal: opens the OAuth popup synchronously on the save click (browsers
block window.open once an async boundary is crossed), validates, then hands
the popup to onSave which navigates it to the authorize URL.
- New CustomConnectorModal wraps DevModal and persists every auth type onto the
connector backend (none / bearer / custom headers → create + sync; OAuth →
create with OIDC config + run the authorize popup).
- settings/skill entry now opens CustomConnectorModal; the standalone
AddConnectorModal rich rewrite from the previous commit is reverted to the
canary original (it is only referenced by the unused ConnectorList).
- i18n: dev.mcp.auth.oauth* keys (default + en-US + zh-CN).
Backend stays as in the prior commit (connector create/update accept
bearer/apikey/header credentials; sync + manifest paths apply them).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(connector): route the OAuth auth type through the authorize flow, not the token-less manifest test
Selecting OAuth and clicking "Test connection" called the plugin manifest test
(getStreamableMcpServerManifest), which connects with no token and 401s on any
OAuth-gated server (e.g. Linear MCP / DCR). For OAuth there is nothing to test
without authorizing first, so the button now becomes "Authorize & Connect" and
runs the connector OAuth flow (discovery + DCR + authorize popup), shared with
the footer save button via DevModal.runOAuthFlow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(connector): make connector.create idempotent on (user, identifier)
Re-adding or re-authorizing a custom connector with an existing identifier hit
the user_connectors unique constraint and 500'd. Now an existing row is updated
(reset to disconnected, refreshed name/url/oidcConfig/credentials) and its id
reused, instead of inserting a duplicate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(skill-store): route Add Custom MCP through the connector modal, drop the Custom tab
- Skill Store "Add → Add Custom MCP Skill" now opens CustomConnectorModal
(connector backend + OAuth), matching the settings/skill entry, instead of
the legacy plugin DevModal (installCustomPlugin + togglePlugin).
- Remove the now-redundant "Custom" tab from the Skill Store (custom MCP lives
in the connector list now): drop SkillStoreTab.Custom, its tab option,
CustomList render, and the matching search branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(model-bank): backfill knowledgeCutoff for OpenAI/Claude/Llama/Phi families (batch 1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(model-bank): add family/generation fields with rule-derived data for chat models
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(model-bank): add canonical knowledge-cutoff map with build-time backfill
Adds MODEL_KNOWLEDGE_CUTOFFS (canonical id → YYYY-MM, all values verified
against official provider docs) plus normalizeModelIdForCutoff, which reduces
provider-specific spellings (openrouter/bedrock prefixes, dated snapshots,
-thinking/-fast/-latest/-preview variants, claude dot-versions) to canonical
ids. buildDefaultModelList backfills knowledgeCutoff from the map when a model
card has no inline value, so all aggregator providers inherit cutoffs
automatically; inline values always win.
Covers Anthropic (incl. legacy 3.x), OpenAI, Google Gemini/Gemma, xAI Grok,
Meta Llama, Amazon Nova, and Cohere. DeepSeek/Qwen/GLM/Kimi/MiniMax/Mistral
publish no official cutoffs and are intentionally absent. Anthropic inline
PoC entries migrate into the map (single source of truth).
Cross-checked against the batch-1 inline backfill: 0 value mismatches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(model-bank): correct Claude Sonnet 4.6 cutoff
* ✅ test(model-bank): sync metadata expectations
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(model-bank): add knowledgeCutoff field with Anthropic models as PoC
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(model-bank): add family/generation fields to model card types
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(model-bank): add claude-fable-5 to Anthropic models
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(agent): allow adding directory topics on web when agent targets a bound device
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(agent): deliver sub-agent resume bridge via QStash webhook in queue mode
The callSubAgent completion bridge was a handler-only hook, which lives in
process memory: in queue mode (AGENT_RUNTIME_MODE=queue) HookDispatcher only
delivers webhook-configured hooks, so the bridge never fired — the parent op
stayed parked in waiting_for_async_tool forever after all sub-agents finished.
- Give the bridge hook a webhook config (delivery: qstash) targeting the new
/api/agent/webhooks/subagent-callback endpoint; local mode keeps the
in-process handler. Both paths converge on
AgentRuntimeService.completeSubAgentBridge (backfill + barrier/CAS resume).
- Park-time self-check: after the parked state and operation row are
persisted, re-run the resume barrier once to recover children that
completed before the parent finished parking.
- One-shot verify watchdog: when a completion finds the parent not yet
resumable, schedule a delayed verifyAsyncToolBarrier re-check (no step
lock, CAS-idempotent, never re-arms).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 📝 docs(agent): correct verify-watchdog rationale comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 📝 docs(agent): clarify eventFields trimming rationale
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ♻️ refactor(agent): align subagent-callback with workspace-scoped step worker
Post-rebase adaptation to canary's runtime restructure (#15609):
- Route the webhook bridge through AiAgentService (like the /run step
worker) so the runtime's models stay workspace-scoped — a bare
AgentRuntimeService would be personal-scoped and the tool-message
backfill / resume barrier could miss workspace-scoped rows.
- Extract SubAgentBridgeParams into agentRuntime/types and add the
completeSubAgentBridge passthrough next to executeStep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(agent): fail sub-agent callback loudly on backfill or delivery failure
Address two review findings on the resume bridge:
- completeSubAgentBridge now checks updateToolMessage's { success } result
(it swallows transaction errors instead of throwing) and propagates all
infrastructure failures. The webhook endpoint then returns non-2xx so
QStash redelivers the whole bridge — previously a failed backfill was
acked with 200 and the parent stayed parked forever, since the verify
recheck only re-reads the barrier and cannot retry the backfill.
- New AgentHookWebhook.fallback: 'none' opts a qstash-delivered hook out of
the unsigned plain-fetch fallback, which can never authenticate against a
QStash-signed endpoint and only masked publish failures as silently
dropped 401s. The bridge hook uses it; dispatch escalates such delivery
failures to console.error instead of the debug namespace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(model-runtime): emit stop:abort instead of error when stream request is aborted
When user cancels a streaming request, the provider SDK throws abort errors
(e.g. "Request was aborted"). Previously these were propagated as error chunks,
causing the client to display a provider error message. Now abort errors emit
a stop:abort event through the SSE pipeline, allowing the client to handle
cancellation gracefully.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* 🐛 fix(model-runtime): fix type error in abort pipeline test
Use `as const` for type literal to satisfy StreamProtocolChunk union type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ✅ test(fetch-sse): add planUpgradeAfterFinish to onFinish expectations
#15616 added planUpgradeAfterFinish to the onFinish context but missed
updating fetchSSE.test.ts, breaking 13 tests on canary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(model-runtime): harden abort detection against non-Error throws
isAbortError assumed error.message is always a string, but catch
clauses receive unknown — a non-Error throw (string, object without
message) would make the abort check itself throw inside the stream
error handler, swallowing both ABORT_CHUNK and the first-chunk error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* 🐛 fix(cli): handle agent_run_request in `lh connect` so device dispatch doesn't time out
`lh connect` auto-registers the CLI as a device, so the gateway can pick it
as the dispatch target for a heterogeneous agent run (`agent_run_request`).
But the connect daemon only listened for `system_info_request` and
`tool_call_request` — it never handled `agent_run_request`, so it never sent
`agent_run_ack`. The gateway waited out its ack window and returned
`{error:'TIMEOUT',success:false}`, surfaced server-side as "Hetero agent
device dispatch failed".
Add an `agent_run_request` handler mirroring the desktop app: spawn
`lh hetero exec` fire-and-forget and ack `accepted` immediately. The spawned
process owns the full execution + server-ingest pipeline. It re-invokes the
current CLI entry (process.execPath + argv[1]) rather than relying on `lh`
being on PATH, so it works inside the detached daemon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: bump the cli version
* chore: bump the cli manifest
* 🐛 fix(cli): ack agent run only after spawn succeeds, reject on spawn error
`child_process.spawn` reports a missing/inaccessible cwd asynchronously via
the child's `error` event, after the handler had already sent an `accepted`
ack. The gateway/server then recorded dispatch success while no `lh hetero
exec` process existed to emit `heteroFinish`, leaving the assistant message
stuck instead of surfacing a failure.
`spawnHeteroAgentRun` now resolves on the child's outcome: `accepted` on the
`spawn` event (stdin is written only then), `rejected` on an early `error`. A
rejected ack returns the gateway 422 → execAgent writes a ServerAgentRuntimeError
onto the assistant message, so a failed dispatch is visible. Still resolves in
milliseconds, well within the gateway's 10s ack window.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🐛 fix: skill list/search commands returning empty results
tRPC endpoints return { data, total } but CLI was treating the result as
an array; switch to result?.data ?? [] and update mocks to match.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* ♻️ refactor(hetero-agent): shared subagent-run coordinator + fix device-mode subagent streaming
Remote-device (gateway) hetero runs corrupted SubAgent text on the wire: the
CLI `SerialServerIngester`'s main-agent text-snapshot coalescing was subagent-
unaware, so subagent full-block text got mixed into the main accumulator and
re-`append`ed as `replace` snapshots server-side. Fix: exclude `data.subagent`
text from the coalescer so it forwards raw (the server appends it once).
The deeper cause was duplication: the renderer executor and the server
persistence handler each hand-wrote the SAME subagent-run state machine (lazy
thread create, turn-boundary cut, finalize, orphan drain, chain parenting) —
the epicenter of past hetero subagent bugs. Extract it into ONE pure,
transactional reducer (`reduceSubagentRuns`) in `@lobechat/heterogeneous-agents`
that emits declarative intents; each engine keeps a thin interpreter for its
own I/O (renderer: messageService + live store dispatch; server: messageModel).
The reducer pre-allocates ids so intents carry parentId chains with no
create→backfill round-trip; this needs `messageService.createMessage` to accept
a caller id (threaded through; the model already supported it). Also widened the
message nanoid 14→18 for the higher per-run id volume.
Behavior unifications (vs the two old copies):
- transactional commit-on-success subsumes the renderer's `pendingFlushTarget`
(a failed flush leaves the run intact for the onComplete-drain retry; the
renderer keeps a local pending-flush map pinned to the original assistant).
- finalize DELETES the run (server-style); a second finalize / orphan drain is
a clean no-op with the same DB end-state.
Scoped to subagent runs only; main-agent persistence stays per-engine. A future
pass can absorb the main-agent path into a unified agent-event reducer.
Tests: reducer 13, CLI hetero 22, server hetero 84, renderer executor 58.
Refs: LOBE-10175
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(hetero-agent): strengthen subagent flush-retry assertion
The earlier rewrite of this assertion (caused by ids moving from server-
generated to caller-pre-allocated) weakened it to "all streamed writes share
one id", which would also pass if they all wrongly hit the terminal row. Pin it
back to the test's real intent: resolve the FIRST streaming-turn assistant by
its create payload and assert every streamed write targets it AND that it
differs from the terminal assistant's id — so `resultContent` is never clobbered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(hetero-agent): honor commit-on-success for renderer subagent intents + fix stale id-length tests
- renderer interpreter: createThread / createMessage failures now rethrow so
reduceAndApplySubagent skips the state commit — the next event retries the
lazy create / turn boundary instead of orphaning the run (review P2)
- catch around the intent loop so a failed intent can't poison persistQueue
- regression test: transient createThread failure retries on next event
- update message id length assertions 18 → 22 (nanoid widened 14→18 + msg_)
- update messageService.createMessage spy assertions for the new (params, id) call
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): block nested sub-agent calls
Sub-agents must not recursively spawn further sub-agents. Plumb an
`isSubAgent` flag from the spawning thread through the conversation /
operation / tool-call metadata, and refuse nested dispatch at every layer:
- streamingExecutor marks the spawned sub-agent context with `isSubAgent`
- aiAgent strips the LobeAgent tool from a sub-agent's plugin config
- client builtin-tool executor + server tool runtime return a clear error
- RuntimeExecutors blocks both single and batch sub-agent dispatch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(test): align execSubAgentTask expectation with isSubAgent appContext
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(agent): don't mark group sub-agent tasks as isSubAgent
Group sub-agents are real agent dispatches and must keep the ability to
spawn their own sub-agents; only the LobeAgent-tool virtual sub-agent
path should carry isSubAgent. Drop the flag from execSubAgentTask.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(page-agent): inject active documentId into context on send
Page-scoped conversations never carried the open document id to the
agent runtime. At send time `operationContext` only had agentId/scope/
topicId, so the gateway's `appContext.documentId` was undefined and the
server-side PageAgent runtime threw "received a tool call without
documentId in context".
Inject the live document id from the page editor runtime
(`pageAgentRuntime.getCurrentDocId()`) into `operationContext` when
scope is `page`, so it flows through `execAgentTask` → server
`state.metadata.documentId` → tool execution context.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(page-agent): pass new document id explicitly in sendAsWrite to avoid stale injection
The page-scoped documentId fallback reads the page editor runtime
singleton, which is only authoritative once the active page's editor has
mounted. `sendAsWrite` creates a document, navigates, and sends
immediately — before the new editor mounts — so the singleton may still
be bound to the previously open page, scoping server-side PageAgent
tools to the wrong document.
Thread the freshly created `newDoc.id` through the conversation context;
the existing `!context.documentId` guard then skips the singleton
fallback entirely. Document the constraint at the fallback site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(database): raise model/repository coverage to 95%+ and document DB test conventions
Raise @lobechat/database client-db coverage 89.11% -> 95.36%:
- New integration tests for connector, connectorTool, workspaceMember (were 0%)
- Extend task, workspace, rbac, notification, userMemory/query, file,
agentSignal/reviewContext, verifyRubric, brief, taskTopic, dataImporter,
messengerAccountLink, home
Fix client-db (PGlite) test failures: BM25 search lacks the pg_search
extension under PGlite, so wrap session.queryByKeyword and home.searchAgents
in describe.skipIf(!isServerDB), matching the existing convention.
Document DB model/repository testing conventions so new models ship with tests:
- Rewrite testing skill's db-model-test.md (getTestDB integration pattern,
client-vs-server-db split, BM25 skipIf guard, schema gotchas, user isolation)
- Surface the rule in testing/SKILL.md, cross-link from drizzle/SKILL.md,
review-checklist/SKILL.md, and models/_template.ts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(database): make verifyRubric/brief ordering tests deterministic
These models order by `updatedAt`/`createdAt` desc with no id tiebreaker, and
the tests created rows back-to-back relying on default `now()` — when two rows
land in the same millisecond the order is non-deterministic, causing flaky CI
failures. Set explicit, well-separated timestamps instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- Carry a `reason` payload on the `authorizationRequired` IPC event so the
cause behind the Session Expired modal (proxy 401, refresh non-retryable,
startup proactive refresh exception, etc.) lands in `electron-log` and the
renderer debug namespace for postmortem.
- On 401 + `X-Auth-Required`, enrich the reason with `hadToken`, the upstream
`www-authenticate` header and a truncated body snippet so OAuth/tRPC error
details are captured without consuming the forwarded stream.
- Fix returning users (token refresh failed -> active=false -> relaunch)
landing on the Welcome screen of desktop onboarding. Persist an
`everCompleted` flag in localStorage and resume at the Login screen for
anyone who has already completed onboarding once.
- Extract the screen-resolution logic into a pure `resolveInitialScreen`
helper with unit tests; cover the new storage flag and reason payload in
AuthCtr / BackendProxy tests.
* 🐛 fix(hetero): chain step boundary off tool row when tools[] backfill is unseen
On a warm replica that did not drain the prior step's `tools_calling` (or
before the assistant's `tools[]` JSONB has its `result_msg_id` backfilled),
the in-memory tool state is empty, so the step boundary falls back to the
previous assistant and forks the wire into two disconnected bubbles.
Fall back to the authoritative anchor — the `role:'tool'` rows themselves,
committed in Phase 2 independently of the JSONB mirror's Phase-3 backfill —
via a new `MessageModel.getLastChildToolMessageId`. Excludes subagent tool
rows (threadId set) so they never anchor the main-agent wire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(hetero): write per-device cwd when adding topic from project group
The sidebar "+ new topic in this directory" action wrote the working
directory to the legacy per-agent slot (localAgentWorkingDirectoryMap),
which sits below agencyConfig.workingDirByDevice in the resolution
precedence. Once a directory had been picked via the ControlBar (which
writes workingDirByDevice), the "+" action was silently shadowed and the
new topic was created with the previously-picked directory instead.
Route the action through useCommitWorkingDirectory.commitAgentDefault so
it writes the same high-precedence per-device slot the picker uses,
keeping the two write paths from drifting again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(hetero): cover MessageModel.getLastChildToolMessageId
The fallback anchor query added in 599eea5bda had no DB-level test — the
persistence handler mocks it, so its real SQL was never exercised and
patch coverage dropped. Add direct PGlite tests covering all branches:
latest-tool ordering, no-tool → undefined (ignoring non-tool children),
subagent thread exclusion (threadId IS NULL), and ownership isolation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(server): restore sub-agent forking in QStash step worker
In QStash mode every agent step runs in a fresh HTTP request via the
hono `runStep` handler, which built a bare AgentRuntimeService without
the `execSubAgent` fork callback. As a result `lobe-agent.callSubAgent`
failed with SUB_AGENT_UNAVAILABLE in cloud (the in-process callback
never survives the queue boundary).
Step through AiAgentService.executeStep instead, reusing its internal
runtime that is already wired with the fork callback — no second runtime,
no manual rebinding.
Also rename the internal `execSubAgentTask` → `execSubAgent` (method,
runtime/tool context fields, options, ExecSubAgent{Params,Result} types)
to separate the "task" concept from "sub-agent", and make the method an
auto-bound arrow field so it no longer needs `.bind(this)`. The external
lambda procedure name (`execSubAgentTask`) and the client service are
left unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(server): group runtime upward-calls into an AgentRuntimeDelegate
`execSubAgent` was a loose top-level option on AgentRuntimeService, which
hid that it is not ordinary config but an upward call: the low-level
runtime, mid-step, triggering a high-level pipeline that lives in
AiAgentService (the layer above it).
Introduce `AgentRuntimeDelegate` as the single named home for these
upward-call capabilities, and inject it as `delegate: { execSubAgent }`.
The interface doc states the convention so future "runtime must trigger a
higher-layer pipeline" capabilities land in the same place instead of
sprawling as ad-hoc options.
Scope is deliberately the injection surface (options + service field +
AiAgentService wiring). The downstream executor/tool context keeps its
flat `execSubAgent` field — the tool runner wants the unpacked capability,
not the whole delegate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The lobe-agent manifest exposed `callSubAgents` (parallel multi-task
dispatch), but the server runtime only implemented `callSubAgent`. When an
agent run executed server-side and the model invoked `callSubAgents`, the
builtin executor threw "Builtin tool lobe-agent's callSubAgents is not
implemented".
The server already supports parallel sub-agents natively: a batch parks on
all deferred tools (`pendingToolsCalling`) and `tryResumeParentFromAsyncTool`
enforces a K=N barrier, resuming the parent only once every pending
tool_result is fulfilled. So emitting multiple `callSubAgent` calls in one
turn is equivalent to the old `callSubAgents` — making the plural API
redundant and the source of a server/client inconsistency.
Remove `callSubAgents` end to end (manifest, types, client executor,
Inspector/Render/Streaming components + registries, locale keys, display-name
map, dev fixture) and update the system prompt to guide the model to fan out
via multiple `callSubAgent` calls.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(desktop): preserve Error cause across IPC so renderer sees real failure reason
Electron's IPC error serialization carries an Error's message/stack/name plus
its enumerable own properties, but a standard `cause` (set via
`new Error(msg, { cause })`) is non-enumerable — so the real failure reason
(e.g. undici wrapping ENOTFOUND/ECONNREFUSED under a generic
`TypeError: fetch failed`) was dropped on the way to the renderer.
- IPC base: re-expose `cause` as an enumerable, clone-safe field in the central
handler catch (nested Errors flattened to { name, message, code }) so every
IPC method's error carries it.
- Heterogeneous agent executor: include `cause` in the ChatMessageError body so
the surfaced error structure exposes the underlying reason alongside message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(desktop): ferry IPC error cause via a serializable envelope
Making `cause` enumerable before rethrowing didn't actually reach the renderer:
Electron's `ipcRenderer.invoke` rebuilds a thrown handler error from its *string*
form (`Error invoking remote method '<channel>': <String(error)>`), so the
original error object — and any `cause` — never crosses the boundary.
Switch to an explicit serializable envelope:
- `~common/ipcError`: `toIpcErrorEnvelope` (clone-safe plain object, recursively
captures name/message/stack/code/cause) + `isIpcErrorEnvelope` /
`fromIpcErrorEnvelope` to rebuild a real Error.
- IPC base handler: return the envelope instead of throwing.
- preload `invoke`: detect the envelope and re-throw a rebuilt Error (with
`cause`), preserving the "promise rejects on failure" contract.
- hetero executor: flatten the Error cause to a plain object for the
DB-persisted `ChatMessageError.body`.
Adds unit tests for the envelope round-trip and the preload unwrap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(hetero): add --raw-dump to persist agent raw stream-json for debugging
The remote-device path (`spawnLhHeteroExec`) leaves no local execution
record: `lh hetero exec` consumes the agent's stdout internally and only
POSTs adapted events to the server, so a misbehaving remote run can't be
inspected. The adapted/ingested view also can't distinguish a CC-side
empty `tool_result` from an adapter extraction bug.
Add `lh hetero exec --raw-dump <dir>`: spawnAgent gains an `onRawStdout`
tee that captures the child's untouched stdout BEFORE the adapter; the
CLI writes it (plus stderr + a meta.json) to
`<dir>/<timestamp>-<operationId>/`, one file pair per spawn attempt.
Fully best-effort — a dump failure never affects the run or exit code.
Wire the desktop device path to pass `--raw-dump` (gated by the existing
`shouldTraceCliOutput` toggle, into `resolveTraceRootDir`), so remote-device
CC runs now leave a raw stream on the device — the same toggle/location the
local trace path already uses. Reusable later for the server sandbox path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔖 chore(cli): bump version to 0.0.27
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(file): persist image dimensions into file metadata
Record intrinsic width/height for uploaded images so consumers can
reserve layout space (avoid CLS) without loading the file first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✅ test(file): assert persisted dimensions in upload createFile payload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🔖 chore(cli): bump version to 0.0.26 and regenerate man page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(file): record image aspect ratio alongside width/height
Compute intrinsic aspect ratio (width / height, rounded) at extraction
time and persist it into file metadata so consumers can group/reserve
layout by orientation without recomputing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(chat-input): rename RuntimeConfig to ControlBar
The bar below the chat input now composes mode switcher, execution
device + working directory, approval mode and context window — "runtime
config" no longer matches. Rename the directory, component, and the
showRuntimeConfig / runtimeConfigSlot props (→ showControlBar /
controlBarSlot) across all call sites. Reads as a sibling of ActionBar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent): rename WorkingDirectoryBar to HeteroControlBar
Make the heterogeneous chat-input bar a symmetric sibling of ControlBar:
both compose the shared WorkspaceControls, so naming should match. Rename
the file, component and displayName, and update the controlBarSlot usage.
* 🐛 fix(agent): resolve working directory by target device instead of legacy-only
The chat-input directory picker writes the selection to
`agencyConfig.workingDirByDevice[deviceId]`, but the send / regenerate /
streaming / placeholder paths resolved the agent working directory via
selectors that only read the legacy `localAgentWorkingDirectoryMap`. So a
freshly picked directory was silently dropped and execution fell back to a
default cwd (the app's own repo), losing the user's project and `--resume`.
Make both `getAgentWorkingDirectoryById` and `currentAgentWorkingDirectory`
device-aware: per-device choice > legacy > desktop/home, with the target
device resolved from a passed-in `currentDeviceId` (kept out of the selector
so hook callers stay reactive). Update all call sites to supply the device id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(hetero): forward user images on regenerate so vision input isn't dropped
The hetero regenerate/resend path (`runHeterogeneousFromExistingMessage`)
only forwarded the text prompt to `executeHeterogeneousAgent`, never the
original user message's `imageList`. The send path reads imageList off the
persisted user message and passes it along; this path must too. Without it,
regenerating an image turn re-ran the CLI with no attachments (fully lost
when the session couldn't be resumed, e.g. cwd changed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The chat-input directory picker writes the selection to
`agencyConfig.workingDirByDevice[deviceId]`, but the send / regenerate /
streaming / placeholder paths resolved the agent working directory via
selectors that only read the legacy `localAgentWorkingDirectoryMap`. So a
freshly picked directory was silently dropped and execution fell back to a
default cwd (the app's own repo), losing the user's project and `--resume`.
Make both `getAgentWorkingDirectoryById` and `currentAgentWorkingDirectory`
device-aware: per-device choice > legacy > desktop/home, with the target
device resolved from a passed-in `currentDeviceId` (kept out of the selector
so hook callers stay reactive). Update all call sites to supply the device id.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix: activator tool discovery for cloud-sandbox and local-system
- P0: Explicitly inject LocalSystemManifest when device gateway is configured
(discoverable: isDesktop is always false on server, so it never enters
the discovery loop. The explicit injection mirrors the canUseDevice guard.)
- P1: Skip CloudSandboxManifest when runtimeMode is not 'cloud'
(resolveRuntimeMode unifies executionTarget='sandbox' and legacy
chatConfig.runtimeEnv.runtimeMode paths, so agents with sandbox
disabled correctly exclude the cloud-sandbox tool.)
Both fixes operate at the manifest-map build stage, consistently affecting
all downstream consumers (activator discovery, availableTools, etc.)
* 🐛 fix: remove cloud-sandbox manifest when runtime is not sandbox
The initial manifest seed via getEnabledPluginManifests includes
defaultToolIds (which contains lobe-cloud-sandbox), so the manifest
was already in toolManifestMap before the allowedBuiltinTools loop's
continue guard. This made lobe-cloud-sandbox activatable even when
sandbox was disabled.
Add a delete right after resolveRuntimeMode to cover both the
manifestMap seed and the allowedBuiltinTools loop in one place.
Co-authored-by: chatgpt-codex-connector[bot]
* 🐛 fix: gate local-system injection by runtimeMode === 'local'
🐛 fix(hetero): reset per-message text accumulator at message boundaries
In server-ingest mode (remote-device CC and cloud sandbox both run
`lh hetero exec`), SerialServerIngester's `accumulatedText` spanned the
whole run and never reset across assistant-message boundaries. Combined
with `snapshotMode: 'replace'`, every later message's snapshot re-emitted
all prior messages' text verbatim, which the server persisted into the
new DB message — producing cross-message text duplication.
Reset `accumulatedText` on `stream_start` / `stream_end` (emitted by the
adapter's `openMainMessage`) after flushing the just-ended message's
snapshot, so each message snapshots only its own text.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(heterogeneous-agents): emit per-turn usage for batch-mode Claude Code
Device + sandbox runs spawn Claude Code via the `lh hetero exec` CLI in BATCH
mode (no `--include-partial-messages`), unlike the desktop driver which always
streams partial messages. In batch mode CC emits no `message_delta`, and the
adapter deliberately skipped usage on `assistant` events (assuming the stale
`message_start` echo that only exists in partial mode). The grand-total
`result_usage` is intentionally ignored to avoid double-counting, so batch runs
ended up persisting NO usage at all — the model tag showed no token count.
Track whether any `stream_event` was seen (partial mode); when none has been
(batch mode), emit per-turn usage from the `assistant` event as turn_metadata.
The assistant event's usage is authoritative in batch mode, not a stale echo.
This also fixes the model tag showing `claude-opus-4-8[1m]`: the `[1m]` 1M-context
beta marker only appears in the `system init` model field, while `assistant`
events report the canonical `claude-opus-4-8`. The new turn_metadata carries the
clean id, which supersedes the init-captured one (and matches the id ModelIcon /
pricing lookups expect).
Partial mode (desktop/local) is unchanged — `message_delta` still owns usage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(heterogeneous-agents): update batch-mode E2E for assistant usage
The multi-step E2E fixture has no `stream_event` records (batch mode) and 5
assistant events with `message.usage`, so the new batch-mode path now emits 5
turn_metadata events. Update the expectation from 0 — this validates the fix on
a realistic device/sandbox session: per-turn usage lands with the canonical
model id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(heterogeneous-agents): stop leaking host Anthropic creds into spawned CLI
The local CLI spawn forwarded the entire `process.env` to `claude`, so a
developer with `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL`
exported in their shell had it inherited by the CLI — overriding its own
subscription login and surfacing as a baffling "Invalid API key" + non-zero
exit on every message.
Strip those three vars from the inherited env via `buildInheritedSpawnEnv`.
`session.env` is still spread last, so an agent that explicitly configures an
API key continues to win. Adds regression tests for both the strip and the
override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(skill): consolidate add-skill button into header dropdown
Move the standalone 'AddSkillButton' from SkillList sidebar into the
header '+' dropdown, providing a unified entry point for all add-skill
actions (import from URL/GitHub, upload zip, custom connector).
Replace legacy 'Add Custom MCP' with the new Connector flow.
* 🐛 fix(skill): fix lint - remove unused ChevronDown import, sort imports
* 🐛 fix(heterogeneous-agents): hide "no device" execution target for hetero agents
Heterogeneous agents (Claude Code / Codex) bring their own toolchain and must
execute somewhere, so the 'none' (plain chat) execution target is invalid for
them. Hide the option in the device switcher and never resolve/display 'none'
for hetero agents — fall back to local (desktop) or sandbox (web) instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(topic): use colorText for titles and move "Needs attention" below favorites
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(chat-input): improve runtime config bar layout on narrow screens
Keep chips on a single line (no per-character wrapping), truncate long
labels (working dir / branch / device name) with ellipsis, and let the
workspace cluster scroll horizontally instead of wrapping. On a narrow
bar the hetero "full access" badge collapses to its icon (hover tooltip
still explains it) via a container query.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(topic): show project directory under topic items in by-status mode
Surface each topic's working directory as a muted second line in the
by-status grouping, where rows otherwise carry no project context. Data
is already on the topic metadata, so no extra fetch.
- NavItem: add opt-in `description` slot (single-line layout unchanged)
- DirIcon: convert `renderDirIcon` function into a memo component, add
`size` prop, rename file to PascalCase, migrate all call sites
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(topic): show error alert icon with tooltip on failed topics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(topic): merge attention-needing topics into one "Needs attention" group
Collapse the unread-completion, failed, and waitingForHuman states into a single
top "pending" status bucket (待处理 / Needs attention) so the sidebar surfaces
everything that needs the user's attention in one place.
- groupTopicsByStatus now buckets those three states into `pending`, taking a new
`unreadTopicIds` set (unread completions are a client-only state).
- Server STATUS_SORT_RANK floats `failed` to the top alongside `waitingForHuman`
so failed topics stay on the first page and don't drop out of the group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(topic): pin the "Needs attention" group above favorites
The pending bucket already sorts above running, but the synthetic favorite group
was prepended ahead of it. Hoist pending to index 0 so attention-needing topics
sit at the very top of the sidebar, above both favorites and running.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(heterogeneous-agents): pin resolved cwd onto remote-CC new topics
Remote CC dispatched the run with the correct working directory (the
precedence chain falls back to the agent's per-device pick), but a
brand-new topic was created without `metadata.workingDirectory`, so the
sidebar grouped it under "No directory" / 无目录.
Unify the three drifting server-side cwd-precedence sites behind one
pure helper (`resolveDeviceWorkingDirectory`) and persist the resolved
cwd back onto a freshly-created topic so grouping, next-turn reuse, and
workspace-init scan all agree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a MarketAuthScene ('default' | 'sandbox' | 'mcp' | 'publish') so the
Market authorization modal can show capability-specific copy instead of the
generic "Create Community Profile" wording, while falling back to the generic
copy for unknown scenes.
- Reactive (401) path: infer scene from the tRPC procedure path in the error
link and carry it on the market-unauthorized event.
- Proactive path: callers pass the scene to signIn() (publish buttons, MCP/skill
install, in-chat market tool auth).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(skills): inject pinned skill content into the system prompt
Pinned skills (ids in agentConfig.plugins) were marked activated by
SkillResolver but never carried their content, because resolveClientSkills
dropped the `content` field when mapping store skills to metas. As a result
SkillContextProvider's `s.activated && s.content` filter skipped them, so the
agent had to call activateSkill to use a pinned skill instead of it being
force-injected.
- builtin skill content is already in the store: carry it through.
- pinned DB skill content is fetched on demand (store cache first), only for
pinned ids to avoid bulk network calls when auto mode exposes every skill;
a failed fetch degrades gracefully to a content-less listing.
- resolveClientSkills becomes async; contextEngineering awaits it.
- add skillEngineering tests covering both paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(skills): mark pinned skills activated and fix test types
The MessagesEngine path passes skillsConfig.enabledSkills straight to
SkillContextProvider without running SkillResolver, so the metas must carry
`activated` themselves — content alone is not enough (the provider only injects
`s.activated && s.content`). Mark pinned skills activated in resolveClientSkills,
guarded by content presence so a content-less pinned skill still falls back to
the <available_skills> list instead of disappearing.
Also widen the test helper's param type so `content`/`activated` are accessible
(fixes TS2339 in CI).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(skills): don't pre-activate ZIP-bundled pinned skills
Server-side bundle mounting for execScript / readReference is keyed off
stepContext.activatedSkills, which is populated only by the activateSkill tool
call — operation-level pinning never seeds it. So pre-injecting the content of a
ZIP-bundled DB skill would tell the model to run scripts from an unmounted bundle.
Gate the content pre-injection on the absence of a zipFileHash: bundled skills
stay in <available_skills> and are activated via the tool (which mounts the
bundle), while pure-content skills (builtin Artifacts, bundle-free DB skills)
are still force-injected when pinned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): make working-directory Clear actually clear legacy / default-sourced cwd
The "Clear" action in the working-directory picker was a no-op whenever the
shown directory came from a precedence level that clear() never touched:
- clear() only removed the topic override and the agent's per-device choice
(workingDirByDevice), but the button's visibility was gated on selectedDir,
which also resolves from legacyAgentWorkingDirectory (pre-migration
localStorage pick) and deviceDefaultCwd (device-wide default). When the cwd
came from either, clear() deleted an already-empty higher level → nothing
changed.
Fixes:
- useCommitWorkingDirectory: when clearing at the agent-default scope, also drop
the legacy per-agent value (localStorage-only, no network round-trip).
- WorkingDirectoryPicker: gate the Clear button on hasClearableSelection
(topic / agent choice / legacy) instead of selectedDir, so it no longer
renders as a dead button when the cwd comes solely from the device default
(which isn't clearable from the agent picker).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(claude-code): slow token count-up animation to 2000ms
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Remote/device-spawned CC runs persist via the server-side
HeterogeneousPersistenceHandler (the executing device is not the viewing
client), and the assistant placeholder was created with the agent's
configured chat model/provider (e.g. deepseek-v4-pro). That value leaked
into the model tag and was re-applied at terminal, so the model tag showed
the wrong model instead of the real Claude Code model.
- Create the hetero placeholder with `provider: heteroType` for ALL hetero
agents (not just remote openclaw/hermes) and no model, mirroring the
client path. The real model is reported by the CLI and backfilled.
- Capture the CLI's authoritative model/provider from the first
`stream_start` (CC system/init) and backfill the placeholder, so the real
model lands from the first turn even without usage-bearing turn_metadata.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): list project skills over device RPC in the sidebar
The right-sidebar 技能 (project skills) tab only read skills over local
Electron IPC, so in device mode (working dir on a bound remote device, or
the web client) the list was always empty — unlike the Files / Review tabs
which already branch on `deviceId`.
Add a `listProjectSkills` device RPC mirroring `getProjectFileIndex`:
- types: `DeviceProjectSkillItem` / `DeviceListProjectSkillsResult`
- `deviceGateway.listProjectSkills` via the generic `invokeRpc` relay
- TRPC `device.listProjectSkills` + `GatewayConnectionCtr` dispatch to
`WorkspaceCtr.listProjectSkills`
- renderer chokepoint `projectSkillService` branches on `deviceId`
- `useProjectSkills(dir, deviceId?)`; remote mode lists but doesn't open
previews (parity with the Files tab)
- thread `remoteDeviceId` through `SkillsGroup`
No device-gateway repo change needed — the RPC relay is method-agnostic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): list project skills over device RPC for homogeneous agents too
Thread `deviceId` through the homogeneous resources path
(`AgentDocumentsGroup` → `ProjectLevelSkills`) so a device-bound homogeneous
agent's 技能 tab populates over RPC, matching the heterogeneous `SkillsGroup`.
`useProjectSkills` already accepts `deviceId`; this just wires it in and
OR-s `deviceId` into the `showProjectSkills` gate.
(The large AgentDocumentsGroup diff is prettier re-indentation from wrapping
the outer memo() once the param list crossed the print width.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): resolve per-device cwd in ResourcesSection so device-mode skills load
ResourcesSection computed its working directory with the legacy
`topicCwd || agentCwd` selector, which misses `workingDirByDevice[deviceId]`
and `device.defaultCwd`. For a device-bound agent the cwd lives in that
per-device map, so it resolved to `undefined` — the project-skills SWR key
was null and the fetch never fired even though `deviceId` was set (the 技能
tab showed "暂无可用技能"). Switch to `useEffectiveWorkingDirectory`, the
same resolver the runtime bar / WorkingSidebar use. Fixes both the hetero
SkillsGroup and the homogeneous AgentDocumentsGroup paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(agent): show loading state for project skills while switching path
On a working-directory switch the project-skills SWR key changes, so items
go empty while the new scan is in flight. The homogeneous skills panel was
flashing the empty placeholder instead of a loader. Surface
`useProjectSkills().isLoading` and render NeuralNetworkLoading when project
skills are the only source and still loading. (The hetero SkillsGroup already
shows it via SkillSection's isLoading.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(sandbox): sync user-uploaded files into the cloud sandbox
Pre-load the files a user attached in a conversation (topic message files +
session files) into the cloud sandbox the first time it is used, and tell the
agent they are available.
- FileModel.findFilesToInitInSandbox: merge messages_files (by topic) and
files_to_sessions (by the topic's session), de-duped by file id
- SandboxMiddlewareService.ensureFilesInitialized: on first tool call, presign
download URLs and run an idempotent curl bootstrap into /mnt/data; guarded by
an in-sandbox marker and a short-lived Redis hint, best-effort so it never
blocks the actual tool call (caps: 50 files / 100MB / 120s)
- Agent awareness via {{sandbox_uploaded_files}} in the cloud-sandbox systemRole,
populated by both the server (RuntimeExecutors) and client (contextEngineering)
placeholder generators
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(sandbox): make file sync work on all server runtimes & keep prompt consistent
Address review feedback on the uploaded-files sync:
1. (high) The sync was a no-op on the cloudSandbox server runtime and the skills
runtime because createSandboxService() was called without serverDB, so
ensureFilesInitialized() returned early. Thread serverDB through both.
(heterogeneous sandboxRunner is intentionally left out: it runs a coding agent
in /workspace and does not use the cloud-sandbox systemRole.)
2. (medium) Drop the Redis "already initialized" hint. The in-sandbox marker is
now the single source of truth for idempotency, so a recycled sandbox always
re-syncs instead of being skipped by a stale 5-min Redis key.
3. (medium) Apply the 50-file / 100MB caps inside formatUploadedFilesPrompt (via
the shared selectSandboxInitFiles), so the files the prompt advertises match
exactly what the bootstrap downloads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Guard `signIn()` and the market.* 401 handlers on `isSignedIn` so the
Create Community Profile modal no longer pops up for unauthenticated
users. Routing the user back to LobeChat sign-in is not MarketAuth's
responsibility — callers handle that.
* ✨ feat(heterogeneous-agents): default Codex exec to bypass approvals/sandbox
Switch the default Codex execution mode from --full-auto to
--dangerously-bypass-approvals-and-sandbox, and share the execution-mode
constants from @lobechat/heterogeneous-agents/spawn so the desktop driver
and spawnAgent stay in sync. An explicit execution flag in extraArgs still
wins. Also fix the Codex adapter step tracking so consecutive agent_message
items stay in one step, stale tool completions don't start a new step, and
turn completion drains pending tools before emitting stream_end +
agent_runtime_end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(shared-tool-ui): unwrap shell-wrapper commands in RunCommand UI
Codex execs commands wrapped as `/bin/zsh -lc '...'`; surface the inner
command in the RunCommand inspector and render. Also switch Unix glob
fallback from `find` to `fast-glob` to preserve globstar semantics.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(markdown): render GitHub / Linear / external links as rich chips
Add a markdown Link plugin that rewrites anchor elements into rich inline
chips: GitHub repo/PR/issue/commit/user, Linear issues, npm packages, Figma
files, mailto, and any other external link (favicon + full URL). Citation,
footnote, anchor and relative links keep the default renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ⬆️ chore(deps): bump @lobehub/editor to 4.17.0 and @lobehub/ui to 5.15.10
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitHub redirects the `windows-2025` runner to the new `windows-2025-vs2026`
image, which ships Visual Studio 2026. node-gyp 11.5.0 only recognizes VS
2019/2022, so `electron-builder install-app-deps` fails to rebuild the native
`get-windows` module with "Could not find any Visual Studio installation".
node-gyp 12.x adds VS 2026 detection. Override it in both the root workspace
and the isolated apps/desktop install.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(heterogeneous-agents): default Codex exec to bypass approvals/sandbox
Switch the default Codex execution mode from --full-auto to
--dangerously-bypass-approvals-and-sandbox, and share the execution-mode
constants from @lobechat/heterogeneous-agents/spawn so the desktop driver
and spawnAgent stay in sync. An explicit execution flag in extraArgs still
wins. Also fix the Codex adapter step tracking so consecutive agent_message
items stay in one step, stale tool completions don't start a new step, and
turn completion drains pending tools before emitting stream_end +
agent_runtime_end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(shared-tool-ui): unwrap shell-wrapper commands in RunCommand UI
Codex execs commands wrapped as `/bin/zsh -lc '...'`; surface the inner
command in the RunCommand inspector and render. Also switch Unix glob
fallback from `find` to `fast-glob` to preserve globstar semantics.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(page-editor): enable block plugin with shared inline padding
Mount `ReactBlockPlugin` on the page editor with `anchorPadding={0}` so
the editor root no longer reserves its default 54 px gutters, and apply
`DEFAULT_BLOCK_ANCHOR_PADDING` as `paddingInline` on the `Flexbox`
wrapping `TitleSection` + `EditorCanvas`. This keeps the title and
editor content aligned while leaving the same 54 px of room for the
floating block menu / drag handle to render in.
Requires `@lobehub/editor` with `anchorPadding` support and the
exported `DEFAULT_BLOCK_ANCHOR_PADDING` constant.
* 🐛 fix(page-editor): drop redundant overflowY on editor content wrapper
`editorContent` previously declared `overflowY: 'auto'`, which created
a second scroll container nested inside `.contentWrapper` (already
`overflowY: 'auto'`). With the new inline padding from
`DEFAULT_BLOCK_ANCHOR_PADDING`, the nested scroller clipped the
floating block menu / drag handle that the editor renders in the
inline-padding gutter. Let the outer wrapper own scrolling so the
gutter overflow stays visible.
* ✨ feat(agent): unified per-device working directory + execution-device UI
Client UI consuming the backend contract (#15542). User-facing — validate
before merge.
- New `src/store/device` (SWR fetch + cwd writes) — single source of device data;
`deviceCwd` helper moves here from the chat-input feature layer.
- One `WorkingDirectoryPicker` for local + remote (native dialog vs manual path).
- Shared `WorkspaceControls` strip composed by both chat-input bars.
- GitStatus reads remote git via `useDeviceGitInfo` (read-only).
- Execution-device switcher graduates out of labs → writes only executionTarget.
- One-time migration of legacy localStorage recents into device.workingDirs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): wire executionTarget→runtimeMode + workingDirByDevice cwd
The runtime-decision wiring, kept out of the backend contract PR so it's
reviewed/validated together with the UI that drives it.
- `helpers/executionTarget`: resolveRuntimeMode / executionTarget resolvers.
- server tool gate (AgentToolsEngine) derives runtimeMode from
`agencyConfig.executionTarget`, with a no-regression fallback to the legacy
per-platform runtimeMode.
- server cwd precedence (aiAgent resolveWorkspaceInit + hetero dispatch) now
consumes `workingDirByDevice[targetDeviceId]`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(agent): cover executionTarget + workingDir helpers; drop dead lab key
- Unit-test resolveRuntimeMode / resolveExecutionTarget and the working-dir
precedence (locks the web default→cloud graduation + legacy fallback)
- Remove the now-unused `executionDeviceSwitcher` lab i18n keys (toggle deleted)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): guide web users to the desktop app in the device switcher
On web with no remote device, replace the muted "no devices" dead-end with a
prominent, clickable download-desktop card (and drop the now-duplicate header
link). Desktop keeps the muted hint since local execution is already available.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): fix execution-device copy for desktop + web
- Desktop "no devices" hint no longer tells an already-on-desktop user to
"install the desktop app" — just points at `lh connect`.
- Tighten the web download-card description to the desktop's real benefit
(run on your computer with local file access).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): flatten the web download card to a plain row
Drop the outer border/background so it reads as a normal menu row (like the
sandbox option), and shorten the description to a single line so the row stops
being taller than its neighbours.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): reword download-card desc to "access to your computer"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): add "no device" execution target (plain chat, no run tools)
Restores the option to run an agent with no execution environment, lost when
the per-platform runtimeMode was unified into executionTarget. Adds `none` to
HeteroExecutionTarget (→ runtimeMode `none`), surfaces it at the top of the
switcher on both web + desktop, and flips the web default back to `none` so an
unconfigured web agent is plain chat again (desktop still defaults to local).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): rename HeteroExecutionTarget→DeviceExecutionTarget, reorder switcher
- Rename the type (it now carries `none`, so "device" target fits better than
"hetero") across types + helpers + dispatcher + switcher.
- Move "no device" to the bottom of the list (real targets first, opt-out last).
- Reword the download card to "let agents connect directly to your computer".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): move "no device" back to top, restore EN download copy
"No device" sits above the dynamic device rows; keep the EN download-card
wording as "Run agents with access to your computer".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): swap switcher icons — MonitorOff for "no device", Box for sandbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): clarify execution-device info tooltip + "no device" desc
- Info tooltip now explains the cloud sandbox is provided by the centralized
LobeHub Marketplace, and that picking a device makes it the agent's runtime
for reading/writing files and operating the computer.
- "No device" description now conveys "no device enabled, can't operate a
computer" instead of "plain chat".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): move info icon beside the title, shorten "no device" desc
- Info tooltip trigger now sits next to the "Execution Device" title instead of
right-aligned; the download link stays on the right.
- "No device" description trimmed to just "No device enabled".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): zh tooltip wording — "提供服务"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): reorder tooltip — device runtime first, marketplace last
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): trim tooltip — drop "设备"/devices and trailing period
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): tag the current machine's device row, drop duplicate "This device"
When the desktop's own machine appears in the device list, badge that real row
with a "This device" tag and hide the generic "This device" (local) option —
no more two entries for the same machine. The local option still shows as a
fallback when the machine isn't enrolled in the list yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(agent): hoist this-machine device above sandbox + auto-bind on first run
Switcher-only (no routing/dispatch changes):
- Order is now: no device → this device → cloud sandbox → other devices.
- On desktop, when this machine is enrolled and online and the agent has no
explicit target yet, default to it and persist the binding once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): widen gap between execution-device rows
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): hide "Get Desktop App" link on desktop
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): capitalize "Cloud Sandbox" label
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(agent): web working-dir entry via "Add folder" modal instead of inline input
The browser folder picker can't yield an absolute path (sandboxed handle), so
on web / a remote device the working directory is entered manually. Replace the
inline input with an "Add folder…" row that opens a modal for absolute-path
entry; the local desktop machine still opens the native folder dialog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent): split working-dir footer into local/remote row components
Replace the scattered `isLocalDevice ?` forks (icon, label, handler) with one
branch that picks between two self-contained rows: ChooseLocalFolderRow (native
dialog) and AddRemoteFolderRow (absolute-path modal).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): use the device default cwd as the add-folder placeholder
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): validate manually-entered working dir via device statPath RPC
Web / remote clients can't browse the target device's filesystem, so the
"Add folder" modal now checks the typed path on the device before binding it.
New `statPath` device RPC mirrors gitInfo end-to-end:
- desktop WorkspaceCtr.statPath (fs.stat → exists / isDirectory) + RPC dispatch
- server deviceGateway.statPath + device.statPath tRPC (invokeRpc relay)
- modal blocks on a definitive negative (not found / not a directory); an
unreachable device is treated as "can't verify" and allowed through
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent): route statPath through deviceService, not lambdaClient
Components shouldn't import lambdaClient directly — add a thin deviceService
wrapping device.statPath, and call it from the working-dir picker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(i18n): move working-directory strings from plugin to a device ns
The working-directory / git control-bar strings (53 keys) were lumped under the
`plugin` namespace. Move them to a dedicated `device` namespace and drop the
now-redundant `localSystem.` prefix (`plugin:localSystem.workingDirectory.X` →
`device:workingDirectory.X`). Updates the 4 consumer components; the `device`
ns auto-registers via defaultResources.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent): route all device TRPC calls through deviceService
Components/hooks/stores shouldn't reach into lambdaClient.device.* directly.
Expand deviceService with listDevices/updateDevice/listGitBranches/
checkoutGitBranch/checkCapability/getAgentProfile and migrate every imperative
call site (device store, BranchSwitcher, CreatePlatformAgent, the remote-agent
guard, RemoteAgentConfigCard) + the DeviceListItem type. lambdaQuery.device.*
React-Query hooks are left as-is (a different pattern).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): pull/push a remote device's branch over RPC
Wire git pull/push through the device's pullGitBranch/pushGitBranch RPC so the
web/remote GitStatus bar can sync, not just the local desktop over IPC. Shows
the pull/push affordances for remote devices too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent): route git pull/push through deviceService too
Add pullGitBranch/pushGitBranch to deviceService and switch GitStatus off the
direct lambdaClient.device.* calls, so no component reaches the device router
directly anymore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): detect repoType for manually-added working dirs
A directory added via the "Add folder" modal committed without a repoType, so a
GitHub repo showed a plain folder icon. statPath now also returns the git repo
type (detected on the target device); the modal threads it into the committed
entry. Collapses the modal's separate validate+submit into one onSubmit that
validates and enriches in a single round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(agent): create new branch via a modal instead of inline footer
"Checkout new branch…" now opens a focused modal (branch-name input + create)
rather than expanding an inline footer inside the branch dropdown. Always
creates + checks out the branch — no checkout/overwrite options. Errors show
inline in the modal; drops the dead inline-create state/styles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(agent): route all git ops through a unified gitService
Pick Electron IPC vs device RPC inside the service so UI / store / hooks
stay transport-agnostic. Replace the bundled `gitInfo` device RPC with
granular reads (branch / linked PR / working-tree / ahead-behind) that
mirror the local IPC methods one-to-one, and move the git read SWR hooks
into the device store (useFetchGitInfo / WorkingTreeStatus / AheadBehind).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): route Review git ops through device RPC (remote-capable)
Extend the device-RPC git pipeline to the 4 ops the Review panel needs
(getGitWorkingTreePatches / getGitBranchDiff / listGitRemoteBranches /
revertGitFile), mirroring the listGitBranches pattern end-to-end: desktop RPC
dispatch → deviceGateway → device.* tRPC → gitService. Adds minimal DeviceGit*
mirror types to @lobechat/types. Review (useReviewPatches / useGitRemoteBranches
/ FileItem) now goes through gitService with a deviceId, dropping the isDesktop
gate so web/remote devices get the diff + revert too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): resolve repoType from device store so remote Review tab shows
useRepoType now reads the persisted workingDirs[].repoType from the device
store (keyed by deviceId), so a remote device's git/github type — and thus the
Review tab visibility — resolves without a local-only IPC probe. The IPC probe
+ localStorage fallback are kept only when the target is the local machine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(agent): optimistic branch switch in the branch switcher
Flip the displayed branch the instant a checkout is clicked (or a new branch
created) instead of waiting for the IPC/RPC round-trip + gitInfo refetch. The
git-info SWR cache is optimistically updated and reconciled on completion — a
failed checkout rolls the label back and toasts the error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat: support remote device files panel
* 💄 style: restore desktop this-device option
* 🐛 fix: keep files panel local for this device
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(chat-input): use compact stats footer for skill tools popover
- Replace the two full-width footer rows (store / management) with a
compact stats footer: pinned / auto counts on the left, an
"Add Skills / Connector" store button (icon + label) and a settings
icon button on the right.
- Right-align each item's type tag (MCP / Skills / builtin) so badges sit
flush next to the row action instead of trailing the name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(aiAgent): mock deviceGateway in connectorOverlap exec test
execAgent reads `deviceGateway.isConfigured`, which under the happy-dom
test environment hits real t3-env and throws "server-side env var on the
client". Mock `@/server/services/deviceGateway` like the sibling device
tests do so the connector/plugin overlap cases run in isolation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(desktop): unbreak dev cold-start on non-default UI languages
`ViteRendererFallback` now proxies via globalThis `fetch` (Node undici) instead
of Electron `net.fetch`, and Vite dev server is pinned to IPv4 listen. The
main-process Chromium `net` pool is small and surfaces `ERR_INSUFFICIENT_RESOURCES`
under cold-start module bursts + ~50 i18n namespace fan-out under non-en-US
locales. undici queues internally and avoids that pool entirely; v4 listen avoids
happy-eyeballs dual-stack connect storms. A Semaphore(64) still caps in-flight
fetches so the OS socket layer never gets buried.
Fixes LOBE-10086
* 🐛 fix(desktop): restore persisted UI language across renderer reloads
The renderer's `<html lang>` was being computed from `?lng=` (injected by the
main process at `loadURL` time) with `navigator.language` as fallback. On
`Cmd+R` the webContents reload reuses the prior URL without rebuilding it
against `storeManager.locale`, so users who changed their language after
launch got dropped back to the OS locale on every reload (white screen, then
English). Read the i18next localStorage cache first — that's the actual
persisted user setting written by the language switcher — and fall back to the
URL param + navigator as before.
* ✅ test: mock device gateway in connector overlap spec
* ✨ feat(agent): agencyConfig contract — workingDirByDevice + executionTarget
Type-only contract for the unified per-device working-directory work. Adds
`workingDirByDevice` (per-device cwd) and `executionTarget` to agencyConfig.
No runtime logic consumes them yet — the server/client wiring lands in the UI
PR so it can be validated as one unit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): device gitInfo over RPC + shared local-file-shell git impl
Backend/RPC capability for "git branch / changes / PR for remote devices".
Dormant — no client caller yet; merging changes no existing behavior.
- `@lobechat/local-file-shell/git`: repoType + branch / linked-PR / working-tree
/ ahead-behind + `gitInfo` aggregate + `DeviceGitInfo` type (desktop + CLI).
- desktop `GitCtr.gitInfo()` (@IpcMethod) delegates to it; registered in
GatewayConnectionCtr's RPC dispatch. `utils/git` re-exports the helpers.
- server: `deviceGateway.gitInfo()` wrapper + `device.gitInfo` TRPC query.
- `@lobechat/types`: `DeviceGitInfo` shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✅ test(desktop): fix stale mocks after git impl moved to local-file-shell
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(server): extract DeviceGateway into its own service dir
deviceGateway is a device-scoped gateway client (status/list/tool-call/git/
workspace RPC), not tool-execution-specific. Move it out of toolExecution/
into its own services/deviceGateway/ and update all import sites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(connector): wire custom MCP OAuth — Pre-registration & DCR (LOBE-9983)
Connect the two OIDC schemes designed in LOBE-9736 (oidcConfig) end-to-end so
users can add a custom OAuth MCP server from /settings/skill. Until now the DB
schema, models, and tool-permission UI existed, but nothing ran the OAuth
authorization flow — syncTools only worked when a token already existed.
Flow (shared pipeline, branches only on where client_id comes from):
- Add modal (client_id present → Pre-registration; absent → DCR/RFC 7591)
- startOAuth: probe MCP URL → RFC 9728 protected-resource metadata → RFC 8414
AS metadata; DCR-register the client when no client_id; persist resolved
oidcConfig; build PKCE authorize URL, stash verifier in Redis keyed by state
- /oauth/connector/callback: consume state → exchange code → store encrypted
tokens (KeyVaultsGateKeeper) + tokenExpiresAt + status=connected → postMessage
- syncTools lazily refreshes the access token before connecting
Built on @modelcontextprotocol/sdk OAuth helpers (discover/register/start/
exchange/refresh) — no hand-rolled protocol code.
Security:
- Wire KeyVaultsGateKeeper into ConnectorModel so OAuth tokens are encrypted at
rest (previously the router passed no gatekeeper → plaintext)
- Strip decrypted credentials and oidcConfig.clientSecret from the list response
UI:
- "+" button in /settings/skill Connectors tab opens the Add modal
- SkillList surfaces custom connectors from the connector store
- Modal wires the client secret field, infers the scheme, and shows the
redirect URI to register
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(connector): request server-advertised scopes in OAuth flow
The authorize request sent an empty scope list, so providers that require a
scope (e.g. Linear MCP advertises scopes_supported ["read","write"]) issued a
useless token or rejected the flow. Default to the authorization server's
advertised scopes_supported when the user did not specify any, and use them for
both DCR registration and the authorize request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(connector): let OAuth callback bypass SPA rewrite and auth gate
/oauth/connector/callback is a backend route handler reached via a cross-site
redirect from the OAuth provider, so the proxy middleware broke it two ways:
1. It was not in the backend passthrough list, so it got rewritten to the SPA /
locale shell instead of running the route handler (307 → blank).
2. It was not in isPublicRoute, so BetterAuth treated it as protected; the
cross-site top-level navigation doesn't reliably carry the SameSite session
cookie, so it redirected to sign-in (307).
Add /oauth/connector to backendApiEndpoints and /oauth/connector/callback to
isPublicRoute (the handler validates its own single-use state, so it must not be
session-gated). Scoped so /oauth/callback/success|error SPA pages are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(connector): execute connector tools server-side + agent-runtime wiring
Make custom OAuth MCP connectors actually callable, and sync their tools as
soon as authorization completes.
- callback: after token exchange, sync the tool list server-side via a shared
syncConnectorToolsById — the connector is usable without a client round-trip
- sync.ts: extract buildConnectorMcpParams (http+auth / stdio), shared by
syncTools and the new callTool
- connector router: add `callTool` (resolve connector, hard-block disabled
tools, refresh token, call the remote MCP with decrypted credentials)
- aiAgent runtime: pass a KeyVaultsGateKeeper when resolving connectors so OAuth
tokens decrypt (otherwise tool calls 401); surface connectors in the
agent-management availablePlugins as a new 'connector' type
- AgentManagementContextInjector: render a <connector_plugins> section
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(connector): wire connectors into the classic client chat path
The front-end chat orchestrates tools client-side (via /webapi/chat proxy),
separate from the server agent runtime. Connectors were invisible and
unexecutable there. Wire them in, connector-first.
- toolEngineering: build connector manifests from the store and inject them into
createToolsEngine; drop plugins sharing a connector identifier (connector wins)
- buildClientConnectorManifests: store rows → type 'mcp' manifests (no token; the
client has none) with permission → humanIntervention mapping
- mcpService.invokeMcpToolCall: route connector tool calls to connector.callTool
before the plugin path (only connectors with a real MCP endpoint, so
Lobehub/Klavis skills keep their executor)
- DeferredStoreInitialization: fetch connectors post-login so chat sees them
- AddConnectorModal: refresh after OAuth regardless of popup outcome
- chat-input skills picker: surface custom connectors in the auto group
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(connector): open OAuth popup synchronously + escape callback HTML (codex P1)
- AddConnectorModal: open the OAuth popup synchronously inside the click handler
(before any await), then navigate it to the authorize URL. Browsers block
window.open once an async boundary is crossed, which left popup=null and the
poll loop never resolving — the Add modal hung. Null popup now fails fast with
a "allow popups" message.
- callback route: escape the postMessage payload for `<script>` context
(`<`, `>`, `&`, U+2028/U+2029 → \uXXXX). A malicious OAuth server could put
`</script>...` in the error param and execute script on the app origin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(connector): tighten execution boundary + surface OAuth failures + tests
Address review: enforce the same constraints at the call site that the manifest
layer enforces, and stop swallowing OAuth failures.
- isEnabled on BOTH sides: invokeMcpToolCall only routes enabled connectors
(a disabled connector no longer steals a same-name plugin's call), and the
server rejects calls to a disabled connector. Matches buildClientConnectorManifests
which only exposes enabled connectors.
- callTool requires the toolName to exist in the synced user_connector_tools
list — unsynced / hand-crafted tool names are rejected instead of being
forwarded blindly to the remote MCP.
- extract callConnectorToolById (typed ConnectorToolCallError → tRPC codes) so
the gates are unit-testable.
- AddConnectorModal: distinguish success / provider-error (show the reason) /
user-dismissed instead of collapsing every failure into a silent close.
- tests: exec gates (not-found / disabled connector / unknown tool / disabled
tool / success / token-refresh) + buildClientConnectorManifests mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(connector): align redirect URI, connector-override & partial-failure UX
Second review round.
- redirect URI: the modal showed a client-origin URI while the server sent an
APP_URL one — register-vs-use mismatch broke the callback. Add a
`connector.getRedirectUri` query (server source of truth) and show exactly
that in the modal.
- execAgent: derive the plugin-override set from the connectors that ACTUALLY
produce a manifest (enabled + with tools), not the raw endpoint-having set —
a disabled / not-yet-synced same-named connector no longer evicts the plugin
and leaves the runtime with no tools. Matches the client-chat behaviour.
- partial failure: when code exchange succeeds but the tool sync fails, the
callback now reports `synced: false`; the modal shows "authorized but tools
could not be synced" instead of a false "connected".
Tests: execAgent overlap regression (disabled / 0-tool keeps the plugin; real
tools replace it) + callback partial-failure (synced:false on sync error).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(connector): name the availablePlugins source 'custom' not 'connector'
The agent-management availablePlugins types describe a tool's SOURCE
(builtin / klavis / lobehub-skill); 'connector' named the storage system
instead. Once plugins migrate to the connector table everything is a connector,
so the source-based label is what matters. Rename to 'custom' to align with
ConnectorSourceType.custom (single source of truth); section is <custom_plugins>.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(connector): enforce connector permissions for community MCP plugins
Community MCPs execute via the plugin path (not connector.callTool), so the
per-tool permissions a user sets in the new Connectors UI weren't surfaced:
needs_approval didn't trigger the approval prompt on either runtime. (disabled
was already hard-blocked at execution by ToolExecutionService and the mcp
router.)
- extract patchManifestWithPermissions into a pure, client-safe module
(patchManifestPermissions.ts); connectorPermissionCheck.ts re-exports it.
- execAgent: also patch community-plugin manifests (pluginsWithoutConnectors)
with their connector permissions, alongside lobehub/klavis.
- client createToolsEngine: patch community-plugin manifests with connector
permissions from the store so needs_approval surfaces as humanIntervention
in the classic chat path too.
- unit tests for the shared patch function.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✅ fix(connector): tolerate uninitialized connectors slice in selectors
createToolsEngine now reads connectorSelectors.{customConnectors,connectorList};
toolEngineering/index.test.ts mocks getToolStoreState without `connectors`, so
the selectors hit `undefined.filter`. Guard with `?? []` (the real store always
seeds connectors:[] via initialState) and add connectors:[] to the test mock.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✅ fix(connector): guard every connector selector against an uninitialized slice
mcp.test.ts mocks the tool store without `connectors`, and invokeMcpToolCall
calls connectorByIdentifier → `s.connectors.find` threw. The previous fix only
guarded connectorList/customConnectors; harden all of them (find/filter) so any
partial-store mock is safe. The real store always seeds connectors:[].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gemini 2.5+/3 thinking streams deliver assistant text and reasoning as
content_part/reasoning_part events instead of plain text/reasoning. The
runtime registered no onContentPart/onReasoningPart handlers, so the text
was silently dropped: onCompletion still reported usage tokens, the
empty-completion guard saw outputTokens > 0, and the turn finalized to a
blank `done` (lost in DB, client stream and trace alike).
Add the two handlers, mirroring onText/onThinking for text parts so
streaming, persistence and tracing all capture the content. Image parts
are uploaded to object storage and serialized as multimodal content
(text + image URLs, in order) — never persisting raw base64.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: add June 8 weekly changelog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 📝 docs: add June 8 changelog cover and register index entry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
When Market kicks off OIDC against LobeHub, unauthenticated users are
redirected by the auth middleware to /signin (and onward to /signup).
The utm_source param sent on the original /oidc/auth request was only
buried inside callbackUrl and never surfaced on the sign-up page.
Carry utm_source as a first-class query param through the auth detour,
mirroring how the `hl` locale param is already preserved:
- middleware lifts utm_source from the request onto the /signin URL
- sign-in forwards utm_source to /signup in both navigation paths
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(desktop): bound concurrent Vite dev-server fetches
Since #15304 unified dev under app://, every renderer asset round-trips
through the main-process net stack. A cold start (thousands of module
requests) or a non-default UI language (~50 i18n namespaces over HTTP at
once) could exhaust the net request pool and surface as
ERR_INSUFFICIENT_RESOURCES. Gate Vite dev-server fetches behind a FIFO
semaphore (cap 64), holding each slot until the response body is fully
drained so streaming responses count for their whole lifetime.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(desktop): add trailing inset to tab title
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix: eliminate blank loading state during Gateway/ServerRuntime execution
When sending a message in Gateway (ServerRuntime) mode, the UI showed
a blank state between 'Sending message' and 'Task is running in server'
because the new execServerAgentRuntime operation was associated with the
server-created message ID, while the UI was still rendering the temp
message ID. The temp ID had no running operation, so ContentLoading
returned null.
Fix: pass temp message IDs to executeGatewayAgent and associate them
with the gateway operation alongside the server message ID. This ensures
ContentLoading finds a running operation regardless of which message ID
the UI is currently rendering.
* ✨ feat(agent): animate subagent token count with count-up effect
Promote a shared AnimatedNumber into @lobechat/shared-tool-ui/components and
use it for the subagent metrics token total so it rolls up smoothly while
streaming instead of jumping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The unified /settings/skill manager renders the Connectors and Skills
sub-tabs from one SkillList via viewMode. Lobehub/Klavis OAuth connectors
(type 'lobehub' | 'klavis') belong only in the Connectors view, but the
Skills view's "Community Skill" section still mapped them alongside the
market agent skills — so Gmail, Notion, Google Drive, etc. showed up in
both tabs.
Render only market agent skills in the Skills view; OAuth connectors stay
exclusively under the Connectors view's "OAuth Connectors" group.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🗃️ feat(database): add verify system tables for agent run delivery checker
Implement the database layer for the Agent Run delivery checker (Verify System).
Reuse / definition layer:
- verify_criteria: a single reusable pass/fail standard (atomic unit), carrying
its verifier config + onFail default and bound to a document for judging
guidance (iteration history reuses document_history; no version columns)
- verify_rubrics: a named group that aggregates criteria — the reusable unit
- verify_rubric_criteria: junction, which criteria a rubric aggregates
(criteria are reusable across rubrics)
Mounted onto an agent via the existing agency config jsonb:
- agencyConfig.verifyRubricId: a reusable rubric (criteria template)
- agencyConfig.verifyCriteriaIds: ad-hoc one-off criteria
A run's plan instantiates the union of both. No dedicated bindings table.
Snapshot + result layer:
- agent_operations.verify_plan (jsonb) + verify_plan_confirmed_at: the per-run
immutable check-item snapshot lives ON the operation (1:1 — auto-repair spawns
a new operation), instead of a separate plans table
- agent_operations.verify_status: denormalized rollup for list-page badges
- verify_check_results: per-criterion result with the Toulmin model
(verdict/confidence as columns, narrative in a typed toulmin jsonb), N:1
verifier_tracing_id for batch judging, FP/FN flags for the data flywheel;
relates to the plan via operation_id + stable check_item_id
Ref: LOBE-10019
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(verify): add Agent Run delivery checker backend + frontend module
Implements the verify system on top of the schema (PR #15480):
- models: verifyCriterion / verifyRubric (+junction) / verifyCheckResult;
agentOperation verify plan/status methods
- services/verify: AI plan generation (auto-create criteria), executor with
LLM Toulmin judge (per-criterion + batch), program placeholder, agent &
auto-repair spawner seams, rollup chokepoint, feedback fp/fn, completion
lifecycle bridge
- lambda verify router (criteria/rubric CRUD, plan, results, feedback)
- frontend feature module: service, SWR hooks, CheckerDock state machine,
RunArtifact, verify i18n namespace
- tracing scenarios: VerifyPlanGen / VerifyJudge
Live UI mount (dock/artifact into chat) pending server operationId source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(verify): persist delivery-checker verdicts via async tracing backfill
The LLM judge produced valid verdicts but they were never persisted, leaving
every run stuck at `verifying`. Two root causes:
1. FK ordering: `writeVerdict` stamped `verifier_tracing_id` synchronously, but
the `llm_generation_tracing` row is written asynchronously (best-effort,
after the response) — so the hard FK was violated every time and the verdict
write was rolled back. Now the verdict is written with a null link, and the
tracing id is backfilled by an `onPersisted` callback that fires only after
the tracing row commits (still non-blocking). If tracing is disabled the link
simply stays null.
2. Verdict parse: the judge JSON schema is non-strict, so the provider returns
optional Toulmin fields as explicit `null`. The Zod validator used
`.optional()` (accepts undefined, not null), so any null failed the whole
`safeParse` and discarded the batch. Switched to `.nullish()`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(cli): add `verify` command for the delivery checker
Adds `lh verify` covering the full delivery-checker chain — criteria & rubric
CRUD, per-run plan (generate/state/confirm/skip), execute (LLM judge), results,
and feedback — calling the `verify` lambda router. Enables end-to-end backend
testing of the verify system.
Also adds the missing `tool-runtime` / `prompts` / `const` workspace entries to
the CLI's `pnpm-workspace.yaml` so the standalone package installs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(verify): add verify message role + delivery-checker card UI
Make the delivery-checker renderable in chat:
- Fix the `features/Verify` components so they compile: flatten the `verify`
locale to the repo's flat-dotted-key convention (keySeparator: false), import
`Flexbox`/`TextArea` from `@lobehub/ui` (react-layout-kit is no longer a dep),
and the token cast.
- Add a `verify` UI message role + a `VerifyMessage` card that renders the
Run Artifact + checker dock from `metadata.verifyOperationId`, wired into the
message renderer switch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): add lobe-agent `generateVerifyPlan` tool (server runtime)
Lets an agent set up the delivery checker for its run: the agent calls
`generateVerifyPlan` early (per the new `<delivery_checker>` system-role
guidance), which instantiates the rubric / ad-hoc criteria into a frozen plan on
the current `agent_operations` row. Executed server-side only — the executor is
dispatched via `runtime[apiName]` with `operationId` threaded through the tool
execution context; the client `BaseExecutor` gracefully no-ops it.
Also registers the metadata fields (`verifyOperationId`/`verifyRound`) on the
message metadata zod schema so the role='verify' card can carry its operation id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): surface role=verify card on run completion (LOBE-10051)
Connect the delivery checker to the conversation: when an Agent Run with a
verify plan completes, `CompletionLifecycle` inserts a persisted `role='verify'`
message (parented to the assistant, carrying `metadata.verifyOperationId`) that
renders the checker card. Self-guarded — no plan → no card, failures never
affect the run.
`role='verify'` behaves like a `user` leaf message everywhere it flows
(persistence + conversation-flow pass it through unchanged); only the
context-engine treats it specially: a new `VerifyMessageProcessor` drops it from
the model context (UI-only card, not a valid model role). Adds `verify` to
`CreateMessageRoleType`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(verify): merge run-artifact + checker into one card
The role=verify message rendered two stacked cards (Run Artifact summary +
Delivery Checker) that duplicated the check-item list. Merge into a single card:
the `Run Artifact · Round N` header, then the checker results + actions, then the
snapshot note. RunArtifact/CheckerDock gain an `embedded` prop (header-only /
body-only, no card chrome) and VerifyMessage composes them under one border.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): derive generateVerifyPlan rubric from agencyConfig
A real agent calls `generateVerifyPlan` with just a `goal` and doesn't know
rubric ids. When `rubricId`/`criteriaIds` params are absent, derive the mounted
rubric + ad-hoc criteria from the executing agent's
`agencyConfig.verifyRubricId / verifyCriteriaIds`. Params still win when given.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(cli): surface agent gateway WebSocket close code + reason
The `onclose` handler logged `String(event)` → the useless "[object
CloseEvent]". Surface `event.code` (+ `event.reason` when present) so a gateway
disconnect before completion is actually diagnosable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 fix(verify): rename "Run Artifact" → "Verification", drop failed red border
- The kicker said "Run Artifact" — it's automated verification, not an artifact.
Renamed to "Verification · Round N".
- Removed the red error border on a failed check — a normal card reads better.
- Fixes a render crash (`useVerifyState is not defined`): the border removal left
a dangling reference after the import was dropped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(cli): poll run status when the agent stream drops
When the live stream (gateway WebSocket / SSE) closes before the run finishes,
the run is still executing server-side — so instead of hard-exiting, fall back to
polling `aiAgent.getOperationStatus` every 10s until the run reaches a terminal
state (or is no longer tracked). Pairs with surfacing the WS close code/reason.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 feat(verify): add Render for generateVerifyPlan tool call
The generateVerifyPlan tool call rendered as the default param/result dump. Add a
Render that lists the generated delivery checks (title + gate/auto-fill tag), and
surface the items on the tool state so the Render can read them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): auto-confirm generated plan so checks run on completion
The agent generated a plan but it stayed `planned`/unconfirmed, so the completion
hook (which gates on a confirmed plan) never ran the checks — the card was stuck
at "awaiting confirmation" with no pass/fail. In the headless agent flow there's
no one to click Confirm, so `generateVerifyPlan` now auto-confirms the plan it
generates; the checks then run automatically on completion. (An interactive
"review before run" gate is a future enhancement.)
Also: the verify card header disappeared in the draft/planned phase
(`phaseToArtifact.draft` was null). Give it a header so the card always shows its
"Verification · Round N" heading.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-tracing): only count opaque/presentational attrs as structural noise
The first structuralNoiseRatio charged ALL markup (every <...> tag) as noise,
which over-penalized legitimately structured results 3x. Grounding against real
web-search output (`<item title="…" url="…">snippet</item>`) showed the tags and
the title=/url= attributes ARE the signal the model reads.
Now only opaque/presentational attribute names (id, class, style, data-*, aria-*,
role, on*) count as noise; semantic element tags and content-bearing attributes
(title, url, href, name…) are kept. On a 57-op user-interrupted sample this drops
web-search noise 42%→0% and overall estimated waste 16%→5%, leaving large-payload
(readDocument) and high error-rate tools as the real signal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): model-authored criteria with name/description/instruction-in-document + agent verifier
Restructure the generateVerifyPlan tool to a createDocument-style full-create flow
and wire up the agent verifier path:
- criteria now = title + description (required one-liner) + instruction (required
detailed rubric); instruction lives in a linked document (verify_criteria.documentId),
description is a new verify_criteria column (migration 0111). verifierConfig no
longer holds description/instruction.
- generateVerifyPlan creates verify_criteria + a rubric, snapshots the plan onto
the operation and confirms it; judge resolves the instruction from the document.
- agent-type checks run as verifier sub-agents (execAgent + isolated thread) whose
onComplete hook parses a VERDICT and writes it back to verify_check_results
(renamed AgentVerifierSpawner → VerifierAgentRunner).
- UI: custom Inspector for the tool header; check list shows per-verifier-type icons
(llm/agent/program) + description + required/optional tag; i18n en/zh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ⚡️ perf(verify): run program/llm/agent checks concurrently on completion
The three verifier kinds are independent; previously the agent spawn waited for
the batched LLM judge to finish. Run them via Promise.all so agent sub-agents
start immediately alongside the LLM batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): dedicated builtin verify-agent + writeback tool, role=verify message, portal check editor
- Add `@lobechat/builtin-tool-verify` (submitVerifyResult) + builtin `verify-agent`;
agent-type checks now run as the dedicated verify agent (not the user's agent),
which investigates and writes its verdict back via the tool during its run.
- Verifier inherits the parent run's model/provider (builtin default may be
unconfigured locally).
- role=verify completion message no longer requires an assistantMessageId, so the
delivery-checker card always surfaces when a plan exists.
- Portal editor for verify checks (title/description/instruction/verifier/onFail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(verify): restrict verify-agent to its writeback tool; fix running loader icon
Root cause of stuck `running` agent checks: the verify-agent ran in agent mode and
inherited all default tools (web-browsing, cloud-sandbox, skills, activator), so it
went off web-searching/crawling to "investigate" and never called submitVerifyResult.
- Run the verify-agent in chat mode (enableAgentMode: false, searchMode: off) — the
strict whitelist — and whitelist `lobe-verify` for chat mode so the verifier gets
ONLY its writeback tool.
- Sharpen the verify systemRole: judge from the provided deliverable/instruction
(no external tools), always reach a verdict, and always call submitVerifyResult.
- CheckerDock: running check now uses the standard RingLoadingIcon (warning ring),
matching the app's loader instead of a blue spinner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): auto-repair loop — re-run the agent with failure feedback on failed checks
When required checks fail with onFail=auto_repair, automatically run a second
iteration instead of ending at `failed`:
- createRepairRunner: re-runs the SAME agent in the same topic with the failure
feedback as the prompt, re-snapshots the plan onto the repair operation and
confirms it so it re-verifies on completion (the next round). Capped at
MAX_REPAIR_ROUNDS via parent-chain depth to prevent runaway loops.
- maybeAutoRepair: fires only once every required check has a terminal result, so
it works for inline LLM checks (triggered from lifecycle) and async agent checks
(triggered from the verify tool's writeback path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): open check result detail in portal & rename artifact→result
- add a VerifyResult portal view: clicking any check row opens that result's
detail (verdict, confidence, Toulmin sections, suggestion) on the right; agent
checks expose their execution trace from inside the panel
- CheckerDock rows are all clickable now (chevron affordance), status shown by
icon only; verify card uses colorBgElevated
- rename the run-result surface from "artifact" to "result" everywhere: RunArtifact
→ RunResult, phaseToArtifact → phaseToResult, and all `artifact.*` i18n keys →
`result.*`
- ship verify namespace zh-CN / en-US locales
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): enrich check result portal — criterion stepper, richer detail view
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): rubric run-policy config + repair feedback on the verify card
Auto-repair feedback now lives on the failed round's role=verify message
(content), and the VerifyMessageProcessor surfaces it into the repair run's
context as a tagged user turn — so the repair op runs off history via a new
execAgent `suppressUserMessage` path instead of injecting a synthetic user
message. createVerifyMessage is awaited before verification to avoid a race.
maxRepairRounds becomes a rubric-level config: new `verify_rubrics.config`
jsonb column, read live at repair time via the plan's sourceRubricId. Adds a
RubricConfig portal panel (reachable from the plan card's settings affordance)
to view/edit it, wired through the verify store + TRPC.
Verify domain types/vocab/config are extracted from the DB schema into
@lobechat/types as the single source of truth; schema and consumers import
from there.
Tests: VerifyMessageProcessor dual behavior; VerifyRubricModel config
round-trip; MessageModel.findVerifyMessageByOperationId.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🗃️ refactor(verify): squash the 3 verify migrations into one
Collapse 0110 (tables) + 0111 (criteria.description) + 0112 (rubrics.config)
into a single regenerated 0110_add_verify_tables so the PR ships one clean,
idempotent migration. No schema change vs the three combined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(cli): verify rubric run-policy config commands + shrink judging-rule editor font
CLI: `verify rubric create --max-repair-rounds`, `verify rubric view`, and
`verify rubric update` exercise the rubric config endpoints end-to-end; adds a
mocked command test. UI: judging-rule editor font 16px → 14px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(verify): editable rubric name in the config panel + default 3 repair rounds
Add a name (title) field to the RubricConfig portal, persisted via a new
updateRubricTitle store action + service (optimistic + debounced, alongside
the config write-back). Bump DEFAULT_MAX_REPAIR_ROUNDS 2 → 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(verify): extract generateVerifyPlan into installable lobe-delivery-checker tool
Move the delivery-checker plan-creation flow out of the always-on lobe-agent
tool into a new standalone, installable builtin tool `lobe-delivery-checker`
(Skill Store, opt-in per agent — not loaded by default). lobe-agent no longer
ships generateVerifyPlan.
- new packages/builtin-tool-lobe-delivery-checker (manifest/types/systemRole +
client Render/Inspector/Portal moved wholesale from lobe-agent)
- new serverRuntimes/lobeDeliveryChecker.ts (generateVerifyPlan moved out of
lobeAgent.ts), registered alongside verifyResult
- registered installable in builtin-tools (no hidden/discoverable:false, not in
defaultToolIds/alwaysOnToolIds/runtimeManagedToolIds); renders/inspectors/
portals/identifiers wired; lobe-agent portal entries removed
- i18n keys moved builtins.lobe-agent.verifyPlan.* → builtins.lobe-delivery-checker.*
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent): add `custom` tool mode; verify agent uses it instead of chat-mode
Chat mode's contract is to strip ALL user/agent plugins (strict KB/memory/web
allow-list) — so the verify sub-agent couldn't get its writeback tool without a
leaky blanket rule. Introduce a third tool mode `custom` where the toolset is
EXACTLY the agent's declared plugins (no always-on, no defaults, no activator),
for focused builtin sub-agents.
- chatConfig.toolMode: 'agent' | 'chat' | 'custom' (overrides enableAgentMode)
- AgentToolsEngine: custom branch (defaultToolIds = plugins, rules = plugins-on,
allowExplicitActivation only in agent mode); chatModeRules restored to strict
- verify agent → toolMode: 'custom'; lobe-verify dropped from chatModeAllowedToolIds
- test: custom mode enables exactly the declared plugin, no always-on / defaults
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
✨ feat(conversation): show running indicator after a settled inline tool while generating
Heterogeneous agent turns render a single tool call inline (no
WorkflowCollapse chrome). Once that tool settles but the run is still
generating the next step, the inline path showed nothing below it — a
blank gap that reads as "stuck". Render the same turn-start "running"
indicator at the segment tail for this case. Multi-tool segments keep
WorkflowCollapse's own streaming header; a tool still executing is
already covered by its loading placeholder.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🎨 refactor(local-system): preserve ANSI escape codes in command output
The client now renders ANSI sequences, so stripping color codes from
shell command output is no longer needed. Drop the stripAnsi helper and
let truncateOutput keep the raw colored output intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(local-system): drop dangling ANSI escape and reset open SGR state before truncation notice
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(desktop): move backend URL rewrite into main process
Renderer code no longer needs `withElectronProtocolIfElectron` to rewrite
backend URLs to `lobe-backend://`. The Electron main process now diverts
backend-prefixed paths (`/trpc`, `/webapi`, `/api/auth`, `/market`) to the
remote LobeHub server in two places:
- prod: `RendererProtocolManager` (`app://` handler) delegates to
`BackendProxyProtocolManager.proxy(request, session)` after the existing
hostname guard.
- dev: `Browser.setupRemoteServerRequestHook` registers a
`webRequest.onBeforeRequest` listener that redirects
`http://localhost(:*)/<backend-prefix>...` to `lobe-backend://lobe<path>`.
`BackendProxyProtocolManager` keeps a per-session `WeakMap<Session, Context>`
and exposes `proxy(request, session)` so the same OIDC token / Vercel cookie
/ 401 debounce / `X-Auth-Required` pipeline serves both entry points.
The helper and ~35 call sites in `src/services/_url.ts` and the three tRPC
clients are removed. `ELECTRON_BE_PROTOCOL_SCHEME` stays for the main
process; new `BACKEND_PATH_PREFIXES` + `isBackendPath` predicate live in
`apps/desktop/src/main/const/protocol.ts`.
* ♻️ refactor(desktop): decouple renderer protocol from backend proxy via interceptor pipeline
`RendererProtocolManager` no longer imports `BackendProxyProtocolManager` or
`isBackendPath`. It exposes a generic `addRequestInterceptor(fn)` hook and
runs interceptors in order inside the `app://` handler — first non-null
Response short-circuits the file pipeline.
`BackendProxyProtocolManager.createAppRequestInterceptor()` owns the
"what counts as a backend path" knowledge and returns a 502 for backend
prefixes when no proxy context is wired up (must not fall through to SPA
HTML).
Wiring happens in `App.ts` after `RendererUrlManager` construction —
composition root knows both modules so neither has to know the other.
* ♻️ refactor(desktop): unify dev/prod renderer under app:// and drop lobe-backend://
Dev mode no longer uses `http://localhost:<port>` as the renderer origin; the
BrowserWindow now loads `app://renderer/` in both dev and prod. Non-backend
requests fall through to a strategy:
- prod: `StaticRendererFallback` serves the static export from `rendererDir`
(Range support, SPA HTML fallback, 404 handling)
- dev: `ViteRendererFallback` proxies to the electron-vite dev server via
`net.fetch('http://localhost:5173/<path>')`; HMR WebSocket connects
directly (configured via `server.hmr.{host,clientPort}` + `strictPort`)
`lobe-backend://` is gone — the scheme, its privileged registration, the
`session.protocol.handle('lobe-backend', ...)` call, and the dev
`webRequest.onBeforeRequest` trampoline are all removed.
`BackendProxyProtocolManager` now only stores per-session context and
exposes `createAppRequestInterceptor()` for the `app://` pipeline.
Dev userData is pinned to `<appData>/lobehub-desktop-dev` via a new
`pre-app-init.ts` that runs before `@/const/dir` captures
`app.getPath('userData')` — necessary because dev and prod now share the
`app://renderer` origin and would otherwise collide on localStorage /
cookies / IndexedDB.
Also adds `stream: true` to the `app` scheme privilege so dev media Range
requests survive forwarding.
🗃️ feat(db): delivery-checker schema + ai_providers/ai_models surrogate `_id`
The DB layer, split out so it merges ahead of its callers (services / TRPC /
store / UI ship in a follow-up stacked PR). One consolidated, idempotent
migration (0110_add_verify_tables_and_ai_infra_id):
- verify delivery-checker: verify_criteria / verify_rubrics (+ config) /
verify_rubric_criteria / verify_check_results tables + verify_status /
verify_plan / verify_plan_confirmed_at columns on agent_operations; plus the
verify domain types/vocab/config in @lobechat/types the schema imports.
All four verify tables carry a workspace_id FK + index (cascade on workspace
delete), matching documents / agent_operations. verify_check_results has a
UNIQUE (operation_id, check_item_id) index — one lifecycle row per plan item
per run, so a retry / concurrent worker can't create conflicting duplicates.
- ai-infra (LOBE-10072): nullable `_id uuid DEFAULT gen_random_uuid()` on
ai_providers / ai_models, written as the safe two-step form (ADD nullable,
then SET DEFAULT) to avoid a full-table rewrite + ACCESS EXCLUSIVE lock;
backfill + NOT NULL are later manual steps (LOBE-10073 / LOBE-10074)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(page-agent): execute tools server-side via HeadlessEditor
Page-agent tools (initPage / editTitle / getPageContent / modifyNodes /
replaceText) now run on the server against a `@lobehub/editor/headless`
instance and persist through `DocumentService.updateDocument`, instead
of executing inside the renderer's Lexical instance. The renderer
applies the resulting snapshot via the builtin-tool `onAfterCall` hook,
so the document store stays in sync without an extra fetch.
This makes page-agent execution independent of the client lifecycle
(editor unmount, tab switch, network blip), gives us full server-side
tracing for free (OTel gen-ai + agent-signal + documentHistories), and
exposes a `silent-no-op` / `unexpected-mutation` invariant when the
exported editorData hash diverges from what the handler reported.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 🐛 fix(page-agent): decouple EditorRuntime from @lobehub/editor side-effecting bundle
EditorRuntime statically imported LITEXML_*_COMMAND from @lobehub/editor,
which pulls ReactSlashPlugin and crashes Node (`document is not defined`)
in any server-side test that transitively touched the runtime. The same
import also dispatched the wrong command identity on HeadlessEditor's
kernel — pnpm resolves @lobehub/editor to a different module copy than
the headless bundle, so dispatchCommand would silently no-op server-side.
Introduce a LiteXMLAdapter strategy: renderer wires command dispatch
against the live editor; server wires HeadlessEditor.applyLiteXMLBatch
/ applyLiteXML so the correct headless-bundle symbols are used.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 🐛 fix(page-agent): restore client-side mutate handlers on PageEditor mount
The main commit dropped `setBeforeMutateHandler`/`setAfterMutateHandler`
under the assumption that page-agent tools always execute server-side.
But the chat-store path (`invokeBuiltinTool` → `PageAgentExecutor.modifyNodes`
→ `EditorRuntime.modifyNodes`) still routes through the client-bound
runtime whenever the LLM dispatcher is the chat slice — it does not
consult `manifest.executors`. Without the handlers, that path mutates
the live editor but skips both `documentHistoryQueueService.enqueueEditorSnapshot`
(loses undo baseline) and `commitEditorMutation(saveSource: 'llm_call')`
(row never persists).
Re-wire both handlers. Server-runtime path is unaffected: it instantiates
its own `EditorRuntime` against `HeadlessEditor` and never sees the
client's StoreUpdater wiring, so the two paths can coexist without
double-writing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ♻️ refactor(editor-runtime): split client / server entries so renderer gets adapter for free
Renderer call sites shouldn't have to opt in to the obvious default
(dispatch LITEXML_*_COMMAND on the live editor). Split the package into
two entries:
- `@lobechat/editor-runtime` — renderer entry; constructor auto-wires
the LiteXML adapter from `@lobehub/editor`. Static-importing this
from Node still crashes (ReactSlashPlugin), so it's the right shape
for the browser only.
- `@lobechat/editor-runtime/server` — server-safe entry; exports the
bare class without touching `@lobehub/editor`. Callers (currently
only the page-agent server runtime) supply their own HeadlessEditor-
backed adapter.
Drops the renderer-side setLiteXMLAdapter patch and a stale comment
block in StoreUpdater.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ♻️ refactor(page-agent): drop LiteXMLAdapter, dispatch commands directly
`@lobehub/editor` 4.16.1 ships the LiteXML command identities through the
side-effect-free `@lobehub/editor/litexml-commands` subpath, so a single command
object is shared across the browser and node bundles and can be imported in Node
without pulling the DOM-dependent editor bundle.
`EditorRuntime` now imports `LITEXML_MODIFY_COMMAND` / `LITEXML_APPLY_COMMAND`
from that subpath and dispatches them straight onto the editor kernel. This
removes the `LiteXMLAdapter` strategy object (`setLiteXMLAdapter` /
`getLiteXMLAdapter`) — a leaky abstraction whose only purpose was to keep the
crash-on-Node command import out of the shared base.
- editor-runtime: dispatch `LITEXML_*_COMMAND` directly; delete the adapter
interface, field, setter and runtime-throw guard.
- Collapse the client/server entry split (its sole reason — isolating the
DOM-crashing import — is gone); both entries now re-export the isomorphic base.
- pageAgent server runtime: drop the HeadlessEditor-backed adapter wiring.
- Bump `@lobehub/editor` to ^4.16.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(editor-runtime): drop redundant /server entry
Now that `EditorRuntime` is isomorphic (LiteXML commands come from the DOM-free
`@lobehub/editor/litexml-commands` subpath), the `./server` entry is byte-for-byte
identical to the root `.` entry. Remove it and point the only consumer
(pageAgent server runtime) at the root entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
electron-builder was floating on `^26.8.1` and the repo commits no lockfile,
so each CI build resolved a fresh version. The canary.12 build (2026-06-07)
picked up 26.15.0, which regressed macOS .app bundle signing: codesign reports
"bundle format is ambiguous (could be app or framework)" and Squirrel.Mac
rejects the update during code-signature validation, so the app never quits
to install — surfacing as "auto-update does nothing".
26.15.0 introduced the two suspect changes (mac signing rework #9822 and the
full app-builder-bin Go→TS replacement #9829). 26.14.0 predates both and does
not touch macOS app-bundle signing/layout. Pinning the exact version cascades
to app-builder-lib / dmg-builder / builder-util (electron-builder pins those
exactly), stopping the toolchain from floating across CI installs.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
On desktop the chat-loading beforeunload guard (preventLeavingFn) blocks
window.close() during quitAndInstall, so the app fails to quit & install
the update. The main process already manages close/quit via keepAlive +
isQuiting, so short-circuit the guard on desktop.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(tools): show app-fixed tools in the chat-input Pinned section
Surface always-on, runtime-owned tools (lobe-agent + always-on infra) read-only
at the top of the Tools popover "Pinned" group, so users can see what the app
keeps active for every conversation. These have no toggle — a Pin indicator with
a hint replaces the per-tool policy menu.
- builtin-tools: add `fixedDisplayToolIds` ([lobe-agent, ...alwaysOnToolIds])
- builtin selectors: add `fixedDisplayMetaList` (reads hidden tools by id)
- useControls: render read-only fixed items, prepend to Pinned, fold into counts
- i18n: add `tools.activation.fixed.hint` + `tools.builtins.lobe-agent.*`
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(tools): make lobe-agent actually always-on; gate fixed display to runtime
The Pinned section was rendering tools that aren't enabled every turn:
- lobe-agent was only enabled when injected into plugins/runtime ids (it has no
rule in the engine, so it defaulted to disabled) — showing it as "always on"
was a UI lie.
- manual skill-activate mode strips manualModeExcludeToolIds (activator,
skill-store) from the defaults, so they're off — but they still showed as fixed.
Fixes:
- Add lobe-agent to alwaysOnToolIds so its core capabilities (plan/todo, sub-agent
dispatch, visual-media fallback) are genuinely on every agent-mode turn. Chat
mode still drops alwaysOn entirely.
- Derive fixedDisplayToolIds from alwaysOnToolIds (single source of truth, no drift).
- Make fixedDisplayMetaList mode-aware: drop manualModeExcludeToolIds in manual mode
so the Pinned list matches what the engine actually enables.
- Update engine tests that asserted the old "lobe-agent off by default" behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(tools): drop fixedDisplayToolIds alias, use alwaysOnToolIds directly
fixedDisplayToolIds was just `= alwaysOnToolIds`; collapse it. The selector now
reads alwaysOnToolIds directly and still applies the manual-mode exclusion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✨ feat(cc): show tool count + token + model metrics on Agent inspector chip
Surface per-subagent progress on the inline Agent inspector row so users can
see how much work has happened without expanding the thread:
- Inspector chip renders `[count] tools · [tokens]` after the description
chip, with the model name in a Tooltip. Tool count = count of `role==='tool'`
child messages; tokens = LAST subagent assistant's `metadata.usage.totalTokens`
(CC's per-turn `message.usage` already includes the full prior context,
so summing would double-count the shared history — the final turn's value
matches the main-agent message-footer convention).
- New `threadSelectors.getThreadDbMessages` reads the raw DB-shape child
messages from `dbMessagesMap[thread_*]` (the display-bound `messagesMap`
bucket only holds the parent + a virtual `assistantGroup`).
- `BuiltinInspectorProps` carries `toolCallId` so the chip can join to its
subagent Thread via `metadata.sourceToolCallId`; propagated from both the
chat Inspector caller and the DevPanel `ToolInspectorSlot`.
Adapter / executor changes so subagent token usage actually flows in:
- `claudeCode.ts` `handleSubagentAssistant` emits a
`step_complete{phase:turn_metadata, subagent}` event when
`raw.message.usage` is present. Subagent assistant events are not
partial-streamed (unlike main-agent), so `message.usage` is
authoritative — no de-stale logic needed. The subagent ctx tag lets
the executor route the usage write onto the in-thread assistant
instead of the main agent's, so CC's `result_usage` grand-total
semantics aren't double-counted.
- Renderer + server `step_complete{turn_metadata}` branches check for
`event.data.subagent` and route to the run's `currentAssistantMsgId`.
Renderer mirrors the write into `dbMessagesMap` via `run.stream.update`
so the chip's selector picks up usage as it lands.
Server-side finalize rolls totals onto `thread.metadata` for the
historical-view cold-load path: tool count from `lifetimeToolCallIds.size`,
tokens from the last in-thread assistant's `metadata.usage.totalTokens`,
plus `completedAt` / `duration`. Done via the existing `threadModel.update`
with an inline metadata read-merge — no new `ThreadModel.updateMetadata`
method or `threadRouter.updateThreadMetadata` endpoint introduced.
i18n: 5 keys under `chat.thread.subagentMetrics.*` in `chat.ts` + zh-CN +
en-US.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 🐛 fix(cc): persist subagent metrics so the inspector chip survives cold-load
The metrics chip (tool count · tokens, model in tooltip) only rendered while
the run streamed — after a reload it vanished on desktop. Two gaps:
- The renderer `heterogeneousAgentExecutor.finalizeSubagentRun` never rolled
totals onto `thread.metadata` (only the server `HeterogeneousPersistenceHandler`
did). On cold-load the child messages aren't hydrated, so the live selector
had nothing to read and the chip's `hasAny` went false. Added the symmetric
rollup (`totalToolCalls` / `totalTokens` / `completedAt` / `duration`),
re-sending the create-time `sourceToolCallId` / `subagentType` / `startedAt`
since `updateThread` replaces the whole metadata column.
- Subagent assistant messages carried no `model`, so the tooltip's model line
never showed. The subagent `turn_metadata` branch now writes `model` /
`provider` onto the in-thread assistant (live tooltip) and persists `model`
onto `thread.metadata.model` (cold-load tooltip); the chip selector falls
back to `thread.metadata.model`.
Also fixes a latent bug both paths shared: finalize read `totalTokens` off
`currentAssistantMsgId`, which by then points at the freshly-created terminal
assistant (no usage), so it always resolved `undefined`. Now tracks the last
non-zero per-turn `totalTokens` on the run — matching the live selector's
"last turn, not a sum" convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(cc): derive subagent chip metrics on read, drop run-state tracking
The chip's tool-count / token / model metrics were captured incrementally on
the subagent run (`lastTurnTokens` / `subagentModel`) and denormalized onto
`thread.metadata` at finalize — in BOTH the renderer executor and the server
handler, so the rule lived in three places and the two finalize paths had to
be kept in sync by hand.
Derive them on read instead, from the child messages (the single source of
truth):
- `aggregateSubagentMetrics(messages)` (new, `src/utils`) is the one rule:
COUNT `role='tool'`, SUM every assistant turn's `usage.totalTokens`, pin the
model. SUM (not last-turn) matches the project's token-usage heatmap
convention — "total tokens processed".
- The chip selector aggregates the in-memory child messages live, falling back
to `thread.metadata.*` on cold-load.
- `threadModel.queryByTopicId` computes the SAME projection in SQL (LEFT JOIN +
GROUP BY, reusing the `usage->totalTokens` index, with a legacy
`metadata.usage` fallback) and folds it onto `metadata`, so cold-load reads a
server-derived value without hydrating the child messages.
Both finalize paths drop the metadata rollup and now only flip thread status
Active; `lastTurnTokens` / `subagentModel` run-state fields are gone. Each
subagent turn still writes its `usage` + `model` onto the in-thread assistant —
those rows are what the read-time aggregation sums over.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
✨ feat(tool-ui): render ANSI escape codes in RunCommand output
Parse ANSI SGR sequences in shell stdout/stderr with anser and emit
styled spans for fg/bg colors, dim, bold, italic, underline, strikethrough.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* ✨ feat(desktop): move panel toggle into titlebar top-left
Place a persistent collapse/expand toggle at the titlebar's top-left
corner on desktop, to the right of the macOS traffic lights. The
NavigationBar now splits into a left group (toggle) and a right group
(back / forward / clock) with space-between: expanded, the right group
hugs the sidebar's right edge; collapsed, the controls cluster at the
left edge like codex.
ToggleLeftPanelButton gains an optional `id` prop so the titlebar
instance can opt out of the shared TOGGLE_BUTTON_ID, avoiding a
duplicate DOM id and NavPanelDraggable's hover-reveal CSS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(desktop): expand untracked directories in git status
`git status --porcelain` defaults to `--untracked-files=normal`, which
collapses whole untracked directories into a single `?? path/` entry.
That trailing-slash path then flowed into `readUntrackedAsPatch` as if
it were a file — `stat()` reported `isFile()=false`, an empty patch was
returned, and the Review panel rendered "无法加载该文件的 diff" against
a directory row. Pass `-u` so git expands those directories into their
individual files; each file then produces a real synthetic patch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 💄 style(desktop): scope titlebar toggle to macOS, hide in-page toggles there
The persistent titlebar toggle now renders only on macOS; Windows/Linux
keep the original right-aligned navigation controls and their in-page
toggles.
On macOS desktop, ToggleLeftPanelButton instances hide themselves (the
titlebar owns the control) unless `forceVisible` is set, removing the
now-redundant sidebar-header and content-header toggles. NavHeader also
skips rendering its empty toggle-only bar in this case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
🐛 fix(database): scope ai-infra upsert conflict targets to personal partial index
The 0110 migration replaces the (id, user_id) / (id, provider_id, user_id)
primary keys with partial unique indexes (WHERE workspace_id IS NULL). A bare
ON CONFLICT target can no longer infer a partial index, so add
`targetWhere: isNull(workspaceId)` (and `where` for onConflictDoNothing) to
every personal-scope upsert. Keeps existing provider/model toggling, ordering
and batch upserts working after the migration.
* ✨ feat(agent): auto-scan project workspace (skills + AGENTS.md) for server agents
When a server agent runs against a bound project directory, scan it server-side
at run start for project skills (.agents/skills + .claude/skills) and root
AGENTS.md/CLAUDE.md, cache the result on devices.workingDirs[].workspace (1h TTL),
surface skills in <available_skills>, and inject instructions into the system role.
Replaces the desktop-only client pre-scan so it works for any run initiator.
- Generic device RPC channel (invokeRpc / rpc_request) for server-internal device
methods, separate from the LLM-facing tool-call path
- New desktop WorkspaceCtr owns project-skill / workspace scanning
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): preserve workspace-init cache on device cwd save
device.updateDevice validates workingDirs as { path, repoType } only, so zod
strips the server-written workspace / workspaceScannedAt cache — an ordinary cwd
pick wiped the 1h workspace-init cache (and web reuse), forcing every later run
to rescan. The cache is server-owned, so re-attach it by path from the stored
row instead of trusting the client to round-trip it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Pure mechanical rename of the server device-relay module/class/singleton
(deviceProxy → deviceGateway, file included) to match the underlying
GatewayHttpClient naming. No behavior change. Split out of the workspace-init
feature PR (lobehub/lobehub#15512) to keep that diff reviewable.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent-runtime): add waiting_for_async_tool parked state for deferred tools
Add a dedicated `waiting_for_async_tool` operation status that mirrors
`waiting_for_human` as a non-terminal, resumable pause, and migrate the
client-tool execution pause off `interrupted` onto it — so `interrupted`
once again means only user-initiated cancellation.
Also add the AgentOperationModel primitives the upcoming server sub-agent
bridge needs: queryByParentOperationId (reconcile child ops) and
tryResumeFromAsyncTool (atomic single-fire CAS).
Foundation for the server sub-agent suspend/resume mechanism (LOBE-9763).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ♻️ refactor(agent-runtime): extract isParkedStatus / isBlockedStatus predicates
Replace the repeated `status === 'waiting_for_human' || ... === 'waiting_for_async_tool' || ... === 'interrupted'`
chains with named predicates so the parked/blocked semantics live in one place
(runtime step-loop break, completion lifecycle completedAt, executeSync pause,
operation isActive).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ♻️ refactor(aiAgent): rename execSubAgentTask -> execSubAgent
Full rename of the service method, its `ExecSubAgentTaskParams`/`ExecSubAgentTaskResult`
types, the tRPC endpoint, the injected `RuntimeExecutorContext`/`AgentRuntimeServiceOptions`
callback, and tests. Group-mode `execGroupSubAgent*` identifiers are intentionally left
untouched. Prep for the server sub-agent suspend/resume work (LOBE-9763).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Revert "♻️ refactor(aiAgent): rename execSubAgentTask -> execSubAgent"
This reverts commit f1ea407d74.
* ✨ feat(agent-runtime): add deferred-tool park infrastructure
Introduce a generic `deferred` result flag (BuiltinServerRuntimeOutput /
ToolExecutionResult). When a tool returns deferred, call_tool parks the
operation (waiting_for_async_tool + pendingToolsCalling) without writing a
tool_result — mirroring the client-tool pause — so the result can be
delivered out-of-band later by a completion bridge. Thread the existing
execSubAgentTask DI seam into ToolExecutionContext so async tools can spawn
a child op without a circular import.
Part of the server sub-agent suspend/resume mechanism (LOBE-9763).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ✨ feat(agent-runtime): park call_tools_batch on deferred tools
Mirror the call_tool deferred-park on the parallel path: deferred (async)
tools are collected during the concurrent batch and, once server tools
settle, the operation parks (waiting_for_async_tool + pendingToolsCalling)
alongside any client tools — so K parallel sub-agents in one round all
resolve before the parent resumes.
Part of the server sub-agent suspend/resume mechanism (LOBE-9763).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ✨ feat(agent-runtime): server callSubAgent async suspend/resume bridge
Turn the server `callSubAgent` path from fire-and-forget into a real
deferred-tool suspend/resume loop (LOBE-9763 Phase 2):
- lobeAgent server runtime: add `callSubAgent` executor returning a
`deferred` result via an injected `ctx.subAgent` runner
- RuntimeExecutors: build a per-tool-call server sub-agent runner that
creates the pending placeholder tool message (anchoring the isolation
thread) and kicks off the child op
- aiAgent.execSubAgentTask: register an onComplete bridge hook that
backfills the placeholder and resumes the parent
- AgentRuntimeService: `tryResumeParentFromAsyncTool` (barrier over
pendingToolsCalling + single-fire CAS + schedule), `refreshMessagesFromDB`,
and the `resumeAsyncTool` branch in executeStep
- queue/local: forward `payload` to the execution callback so local/in-memory
resumes (and human-approval) no longer drop their signal
Tests: callSubAgent executor unit tests, tryResumeParentFromAsyncTool
barrier/CAS unit tests, and a server suspend/resume integration test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-runtime): keep hooks across waiting_for_async_tool park
The async sub-agent resume reuses the SAME operationId, but dispatchHooks
fired onComplete and unregistered all hooks on every non-continue step —
including the waiting_for_async_tool park. That made completion consumers
(webhooks, bot promises, eval snapshots) fire prematurely on the park and
miss the real terminal state after resume.
For waiting_for_async_tool, persist the parked status (the resume CAS reads
it) but skip onComplete and keep hooks registered, so the eventual resume
under the same op still notifies consumers. waiting_for_human is unchanged
(its resume runs under a new operationId).
Found via the server-subagent agent-eval (real LLM, in-memory runtime):
parent now correctly reaches `done` after the sub-op completes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-runtime): unwrap QStash body.payload in runStep handler
QStashQueueServiceImpl nests resume/intervention fields under `body.payload`
(operationId/stepIndex/context stay top-level), but the runStep handler
destructured them from the top level. In production/QStash the resumed step
therefore saw `resumeAsyncTool` (and approvedToolCall/toolMessageId/…) as
undefined and never ran the waiting_for_async_tool DB-refresh/clear-pending
branch — the parent op would stay parked forever. The local queue spreads
payload itself, which masked this in local/eval runs.
Merge `body.payload` over the top-level body so both shapes work. Adds a
handler test asserting the QStash-nested payload reaches executeStep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-runtime): unpark parent when callSubAgent fails to start
When a server callSubAgent child op fails to start, no completion bridge
ever fires, so the parent stayed parked in `waiting_for_async_tool`
forever. The runner now drops the placeholder and signals `started:false`
so callSubAgent surfaces an inline tool error instead of parking the
parent — the batch continues (or parks only for genuinely-deferred
siblings, whose barrier already counts this error result).
Also:
- add isParkedStatus/isBlockedStatus to the @lobechat/agent-runtime test
mock — persistCompletion/getOperationStatus call isParkedStatus, so the
missing export crashed dispatchHooks (swallowing onComplete) and
getOperationStatus, failing 3 AgentRuntimeService tests.
- fix completion-bridge totalToolCalls path (finalState.session.toolCalls
→ finalState.usage.tools.totalCalls; the former never existed).
- remove dead AgentOperationModel.queryByParentOperationId (zero callers).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* ✨ feat(agent-tracing): add tool-result feedback quality analysis (tq command)
Adds a shared, no-LLM analyzer that scores how "clean / LLM-friendly" the
environment feedback (tool return content) is, plus an `agent-tracing tq`
CLI command to preview it over a snapshot corpus.
- src/analysis/toolFeedback.ts: pure analysis lib (reusable core) — per
tool-result metrics (tokens, self-redundancy, structural-noise ratio,
error flag/size, format) + op-level and corpus-level rollups.
- src/cli/tool-quality.ts: `tq` (alias `tool-quality`) — token-size
histogram, dirty leaderboard ranked by token-weighted waste, single-op
drill-down, and --json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent-tracing): guard against undefined histogram bucket in buildCorpusReport
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(claude-code): add per-question custom input to askUserQuestion
Let users write their own answer as the trailing item in each question's
option list, beside picking a numbered choice. Single-select treats the two
as mutually exclusive; multi-select appends the custom text as an extra
entry. Merged into the question's answer at submit, so the bridge formatter
and completed Render need no changes. Draft round-trips via a __custom__:
prefix on the existing askUserDraft map.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(claude-code): split askUserQuestion form & drop draft key prefix
Break the single ~530-line AskUserQuestion.tsx into a folder:
- draft.ts pure helpers (read/buildSubmitPayload/isQuestionAnswered)
- useAskUserForm.ts all state + handlers + draft persistence
- OptionCard.tsx / QuestionPanel.tsx presentational pieces
- index.tsx thin view
Also drop the `__custom__:<question>` draft-key prefix: persist the draft as
a typed object { picks, custom, escapeText, escapeActive } instead of a flat
string-keyed map. The picks/custom split now lives in named fields, so the
only sentinel left is `__freeform__` — and only in the submit payload, which
is the actual bridge contract. No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(claude-code): make AskUserDraft assignable to setInterventionDraft
`setInterventionDraft` takes `Record<string, unknown>`; an `interface` isn't
assignable to it (open to declaration merging, so no implicit index
signature). Switch `AskUserDraft` to a `type` alias, which is closed and
satisfies the index signature. Fixes the tsgo TS2345 in CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(connector): add ConnectorModel, ConnectorToolModel, tRPC router, and inferCrudType util (LOBE-9984, LOBE-9985)
- packages/database/src/models/connector.ts: ConnectorModel with create/delete/query/queryByIdentifiers/findById/update/updateStatus
- packages/database/src/models/connectorTool.ts: ConnectorToolModel with upsertMany (preserves user permission on sync), updatePermission, queryByConnector, queryByConnectorIds
- src/libs/mcp/utils.ts: inferCrudType() — name-based CRUD type inference (delete > update > read > write)
- src/server/routers/lambda/connector.ts: tRPC router with list/create/update/delete/syncTools/updateToolPermission
- src/server/routers/lambda/index.ts: register connectorRouter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): runtime integration — connector-first tool resolution with plugin fallback (LOBE-9986)
- src/libs/mcp/buildConnectorManifests.ts: converts user_connector_tools rows into LobeToolManifest entries; maps permission → humanIntervention ('needs_approval' → 'required', 'disabled' → excluded)
- src/server/services/aiAgent/index.ts:
- queryByIdentifiers(agentPlugins) to find matching connectors first
- filter installedPlugins to exclude connector-covered identifiers
- inject connectorManifests as additionalManifests into createServerAgentToolsEngine
- add connector stdio tools to client executor map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): add connector Zustand store slice (LOBE-9987)
- src/store/tool/slices/connector/: new slice with ConnectorState, ConnectorAction, connectorSelectors
- fetchConnectors, createConnector, deleteConnector, syncConnectorTools, disconnectConnector
- updateToolPermission with optimistic update + rollback
- connectorToolsGrouped selector splits tools into read / write groups
- Wired into ToolStore (initialState + store.ts)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): add Connectors UI feature — list, detail, tool permission editor (LOBE-9988)
- src/features/Connectors/: new feature with two-panel layout (list + detail)
- ConnectorList: groups connectors by Connected / Not connected, Add button
- ConnectorDetail: sync button, disconnect, tool permission groups (read/write)
- ToolPermissionGroup: collapsible with batch set (auto/approval/disable all)
- ToolPermissionRow: three-state toggle auto(✓) / needs_approval(✋) / disabled(🚫)
- AddConnectorModal: name + MCP URL input via @lobehub/ui/base-ui Modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): add Connectors tab to Agent customization panel (LOBE-9989)
- src/store/global/initialState.ts: add ChatSettingsTabs.Connector = 'connector'
- src/features/AgentSetting/AgentCategory/useCategory.tsx: add Connectors tab with LinkIcon
- src/features/AgentSetting/AgentConnectors/: new component listing user connectors with toggle
- toggle calls toggleAgentPlugin(connector.identifier) — reuses agents.plugins[] field
- shows per-connector tool count
- src/features/AgentSetting/AgentSettingsContent.tsx: render AgentConnectors for Connector tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): wire Connectors feature to /settings/connector route
- src/store/global/initialState.ts: add SettingsTabs.Connector = 'connector'
- src/routes/(main)/settings/hooks/useCategory.tsx: add Connectors item (LinkIcon) after Skills in AI config group
- src/routes/(main)/settings/features/componentMap.ts: map SettingsTabs.Connector → '../connector'
- src/routes/(main)/settings/features/SettingsContent.tsx: render Connector tab full-width (no SettingContainer), same as Provider
- src/routes/(main)/settings/connector/index.tsx: route page rendering the Connectors feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): use cssVar.property syntax in createStaticStyles (not function call)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): refactor /settings/skill to unified master-detail tool manager
## Backend
- connector.ts: add syncBuiltinTool — bootstraps user_connectors from builtin manifest api[]
- connector.ts: add syncPluginTools — bootstraps user_connectors from user_installed_plugins manifest
- connector.ts: upsertConnectorEntry helper + resolveDefaultPermission (maps humanIntervention → permission)
- connectorTool.ts: SyncToolInput.defaultPermission — per-tool default for new rows, existing rows preserved
## Store
- connector/selectors.ts: add connectorByIdentifier, connectorToolsGroupedByIdentifier, isSyncingByIdentifier
- connector/action.ts: add syncBuiltinTool, syncPluginTools (idempotent — safe to call on panel open)
## /settings/skill refactor
- index.tsx: two-panel master-detail layout (left: 300px skill list, right: detail + permissions)
- SkillList: add onSelect + selectedIdentifier props, pass through to builtin/mcp items
- BuiltinSkillItem: add onSelect + isSelected (selection highlight, click triggers right panel)
- McpSkillItem: add onSelect + isSelected
- SkillDetail (new): auto-syncs connector entry on mount, then renders ConnectorDetail permission editor
- SettingsContent: Skill tab now renders full-width (same as Provider/Connector)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(skill): createStaticStyles returns static object, not a hook
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(skill): wire onSelect to all skill item types — LobehubSkillItem, KlavisSkillItem + error handling in SkillDetail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): use createStaticStyles correctly — static object, not hook; use string concat instead of cx()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(skill): whole row clickable in list mode, hide action buttons when onSelect provided
All 5 item types (Builtin/Mcp/Lobehub/Klavis/AgentSkill):
- When onSelect is provided (list mode): entire row is clickable, action buttons hidden
- When onSelect is not provided (other usages): original behavior preserved
- Added onSelect/isSelected to AgentSkillItem + wired in SkillList for all agent skill types
- SkillDetail: show friendly message instead of error when skill has no tool permissions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): route sync action by sourceType; improve no-tools skill UI
ConnectorDetail:
- builtin → Reset (syncBuiltinTool from local manifest, resets permissions to defaults)
- marketplace → Refresh (syncPluginTools from installed plugin manifest)
- custom MCP → Sync (syncTools via remote MCP server, existing behavior)
- Hide Disconnect button for builtin/marketplace (only MCP connectors can disconnect)
- Show 'No tool permissions' message when connector has 0 tools
- Fix hooks-rules violation: move useCallback before early return
SkillDetail:
- Catch sync failure cleanly — shows graceful 'no tool permissions' panel
- Show skill identifier as title even when no tools available
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(skill): inline AgentSkillDetail for agent skills; clean ConnectorDetail layout
SkillDetail:
- Add 'agent-skill' ToolDetailType — renders AgentSkillDetail inline (no modal, no connector sync)
- All hooks called before conditional returns (fixes rules-of-hooks)
SkillList:
- Pass type='agent-skill' for market/user agent skills (UUID identifiers, not plugin identifiers)
ConnectorDetail:
- Remove 'Tool permissions / Choose when AI...' subheader — tool groups render directly
- Cleaner layout: name → sync/disconnect buttons → tool groups
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(skill): description in ConnectorDetail header + builtin-skill detail panel
Backend (connector.ts):
- syncBuiltinTool: store manifest meta.description + meta.avatar in connector.metadata
- syncPluginTools: same for plugin manifest meta
- upsertConnectorEntry: always update metadata on re-sync (keeps description fresh)
ConnectorDetail:
- Show connector.metadata.description below name in header
SkillDetail:
- Add 'builtin-skill' ToolDetailType for builtinSkills (Artifacts, Task, AgentBrowser)
→ Shows avatar + name + description panel; no connector sync needed (prompt-based)
- Add 'builtin-skill' type: reads from store builtinSkills array by identifier
SkillList:
- builtinAgent items → pass type='builtin-skill' (not 'builtin') to SkillDetail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(skill): fix crudType for camelCase, show skill content, compact items + categorized groups
inferCrudType (utils.ts):
- Fix: use prefix ^ anchoring instead of \b word boundary
- getReactions/listPins/searchMessages now correctly → 'read' (not 'write')
- \b fails on camelCase: 'getreactions' has no boundary after 'get' (both \w chars)
SkillDetail:
- builtin-skill type: render builtinSkill.content via <Markdown variant='chat'>
- Artifacts/Task/LobeHub skills now show their full markdown content in right panel
style.ts:
- Compact skill items: icon 48→36px, padding-block 12→6px
SkillList:
- Remove old flat renderIntegrations() + Divider
- Add categorized sections with headers:
LobeHub 内置 Tools | 内置 Skill | 社区 Skill | 社区 Tools | 自定义
- Add sectionHeader style
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(skill): collapsible sections, compact items matching reference design
style.ts:
- icon: 28→24px, no background (reference style: plain icon, no container bg)
- padding-block: 4→3px, font-size: 13px
- sectionHeader: collapsible with hover state
SkillList:
- Sections are collapsible — click header to toggle
- ChevronDown/ChevronRight icons on section headers
- All renderSection calls now pass a unique key
All item components (Builtin/Mcp/Lobehub/Klavis/AgentSkill):
- gap: 16→8px (tighter horizontal spacing)
- avatar/icon: 32→22px (matches reference ~24px icon)
- In list mode (onSelect): tag moves to RIGHT side of row
- In list mode: remove tag from title area, status text below title
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(skill): default select first item; + button opens Add custom connector modal
index.tsx:
- Auto-select first installed builtin tool (or first builtin skill) on page load
- + button → opens AddConnectorModal (add custom MCP connector)
- 技能商店 button → still opens skill store (unchanged)
AddConnectorModal:
- Add Advanced settings section (collapsible chevron)
- OAuth Client ID field → stored in oidcConfig.clientId
- OAuth Client Secret field (UI only, encryption path TBD)
- Clear all fields on cancel/submit
Connectors/index.ts: export AddConnectorModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(skill): reference-quality UI polish + Connectors/Skills tab switcher
Style polish (matching linear-tool-permissions demo):
- style.ts: icon 20px, padding-block 6px, font-size 14px (no bold)
- All item avatars: 16px
- ToolPermissionRow: py-10px px-12px, font-mono tool names, 15px icons, hover bg
- ToolPermissionGroup: rounded badge for count, outline 'Custom ▾' batch button
- ConnectorDetail: restore 'Tool permissions' h3 + subtitle
Connectors/Skills tab switcher:
- Top of left panel: Connectors tab | Skills tab
- Connectors: builtin tools + OAuth connectors + community/custom MCPs
- Skills: builtin agent skills + community/user agent skills
- Switching tabs resets selection and auto-selects first item in new view
- + button only shown in Connectors view
SkillList: add viewMode='connector'|'skill' prop with filtered section display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(skill): active permission state + Lobehub OAuth skill tools sync
ToolPermissionRow:
- btnActive: use primary color + primaryBg background (clearly visible selected state)
connector router:
- Add syncToolsFromClient: accepts client-provided tool list for skills that already
have their tool list fetched (Lobehub OAuth skills, etc.)
Store action:
- Add syncToolsFromClient action
SkillDetail:
- Add 'lobehub-connector' ToolDetailType
- For lobehub-connector: reads server.tools from lobehubSkillStore (already populated
after OAuth connect) and syncs via syncToolsFromClient — no remote MCP call needed
SkillList:
- Pass type='lobehub-connector' for Lobehub OAuth items (was 'plugin', wrong path)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ♻️ refactor(connector): replace 'Tool permissions' header with connector description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): show disabled tools in settings UI (only filter at runtime)
connectorToolsGrouped: remove permission !== disabled filter — all tools should
be visible in ConnectorDetail so users can re-enable them. Disabled filtering
already happens at runtime in buildConnectorManifests and queryByConnectorIds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(skill): section lowercase, 4-group tools, remove tags in list mode
SkillList: remove text-transform: uppercase from sectionHeader
ConnectorDetail: split tools into 4 groups — Read / Create / Update / Delete
(maps to crudType: read / write / update / delete)
connectorToolsGrouped selector: return { readTools, createTools, updateTools, deleteTools }
All item components: remove SkillSourceTag in list mode (onSelect provided)
— tags are redundant when section headers already provide categorization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): add Reset permissions button — restore all tools to auto
connector router: resetPermissions endpoint — sets all connector's tools to 'auto'
store: resetConnectorPermissions action
ConnectorDetail:
- Add 'Reset permissions' button — resets ALL tools back to auto (fully open)
- Rename 'Reset'/'Refresh' button to 'Refresh' — clarifies it syncs tool list only
- Two separate concerns: Refresh (tool list) vs Reset permissions (all → auto)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): use excluded.* in onConflictDoUpdate to ensure crudType updates + add description to tool rows
connectorTool.ts:
- Use sql`excluded.crud_type` etc. instead of table.column refs in onConflictDoUpdate
- table.column in set generates self-reference (no-op) in some Drizzle versions
- Now correctly updates crudType when Refresh is clicked (read/update/delete groups will show correctly)
ToolPermissionRow:
- Add description below tool name: 11px, tertiary color, single-line truncate with ellipsis
- Tooltip shows full description on hover (mouseEnterDelay: 0.5s)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): createStaticStyles returns static object not hook in ConnectorItem
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🗑️ chore(settings): remove /settings/connector route — Connectors are in /settings/skill
- Remove src/routes/(main)/settings/connector/index.tsx
- Remove SettingsTabs.Connector from enum and componentMap
- Remove Connectors item from settings sidebar useCategory
- Remove Connector from full-width list in SettingsContent
- Remove unused LinkIcon import from useCategory
ChatSettingsTabs.Connector (agent panel) is separate and unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): disabled tools stay in manifest with blocking description + hard-block at callTool
buildConnectorManifests:
- Disabled tools are now INCLUDED in the manifest (not excluded)
- Description replaced with: '[TOOL DISABLED] The user has disabled this tool and it cannot be executed...'
- humanIntervention: 'required' set for disabled tools so AI is explicitly warned
- AI can inform user the tool is disabled instead of silently not knowing it exists
mcp.callTool:
- Pre-call permission gate: query ConnectorModel + ConnectorToolModel by connector identifier
- If tool.permission === 'disabled': return immediately with "disabled by user" message
- MCP server is never called — the block is enforced server-side regardless of what AI attempts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): add permission gate to klavis.callTool for disabled tools
Gmail (and other Klavis-sourced connectors) use tools.klavis.callTool,
not tools.mcp.callTool, so the previous MCP permission gate didn't apply.
Fix: Add serverDatabase to klavisProcedure, extract connector identifier from
toolName prefix, query user_connector_tools, hard-block if permission=disabled.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🗑️ chore(skill): hide + button (custom MCP connector creation — OAuth flow TBD)
Remove AddConnectorModal entry point from /settings/skill header.
Custom HTTP MCP connectors require OAuth (Pre-registration / DCR) which
is not yet fully implemented. Will be re-added in a future PR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): only replace plugins with connectors that have a real MCP endpoint
Root cause: Lobehub/Klavis OAuth skills are synced into user_connectors via
syncToolsFromClient with mcpServerUrl=null. buildConnectorManifests generates
mcpParams={url:''} for them. After humanIntervention approval, the runtime calls
tools.mcp.callTool({url:''}) → fails silently → empty result.
Fix: only use connectorsMcp (connectors with mcpServerUrl or stdio config) to
replace installedPlugins and build connector manifests. Connectors without a real
MCP endpoint (Lobehub/Klavis) fall back to their original plugin executor path,
preserving the Klavis callTool execution chain and fixing needs_approval flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ✨ feat(connector): centralized tool permission enforcement across all execution paths
connectorPermissionCheck.ts (new shared utility):
- getConnectorToolPermission(): look up permission by identifier + toolName
- buildBlockedToolResponse(): standardized "disabled by user" response
- patchManifestWithPermissions(): patch manifest api[] with DB permissions
ToolExecutionService.executeTool() — centralized disabled gate:
- Queries DB at execution entry for ALL tool types (Lobehub skills, Klavis,
MCP connectors, builtin plugins, and qstash/execAgent async path)
- Hard-blocks 'disabled' tools before any executor runs
- needs_approval handled by manifest humanIntervention (not blocked here)
aiAgent/index.ts — manifest patching for Lobehub/Klavis:
- After fetching lobehubSkillManifests + klavisManifests, query connector tools
- Patch manifests: needs_approval → humanIntervention:'required' (pauses for approval)
- Patch manifests: disabled → blocking description (AI informed, executor blocks)
- humanIntervention system already handles headless auto-reject for qstash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): invokeBuiltinTool falls back to store lookup when payload.source is undefined
Root cause: when a tool call is re-invoked after humanIntervention approval,
the payload comes from the DB-stored message which does NOT persist the `source`
field. `internal_transformToolCalls` sets source correctly but it only runs for
LLM-generated tool calls, not for the approval re-invocation path.
Fix: in `invokeBuiltinTool`, if `payload.source` is undefined, do a live lookup
from the tool store (klavisAsLobeTools / lobehubSkillAsLobeTools) to determine
the correct executor. Applies to Klavis (Gmail, etc) and LobeHub Skills alike.
Also: remove all temporary [DEBUG] console.log statements.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🔨 chore: fix TypeScript errors and test failures after canary rebase
- buildConnectorManifests: LobeToolManifest → ToolManifest (correct export name)
- connectorPermissionCheck: cast permission string to ConnectorToolPermission
- connector.ts model: guard encryptCredentials against null credentials
- ConnectorDetail: String() cast for unknown metadata.description
- AddConnectorModal: move loading to Modal.confirmLoading (correct prop)
- connector/action.ts: break circular ToolStore type reference with Pick<Impl>
- execAgent.disableTools.test.ts: mock ConnectorModel/ConnectorToolModel DB deps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🐛 fix(connector): P1/P3 fixes + test mock coverage after code review
P1 — real MCP disabled tools now appear in manifest:
- ConnectorToolModel.queryAllByConnectorIds: new method without disabled filter
- aiAgent.ts: uses queryAllByConnectorIds for manifest building so buildConnectorManifests
receives ALL tools (including disabled) and can emit blocking descriptions
- queryByConnectorIds (non-disabled filter) retained for runtime hot-path
P1 — Klavis gate works for hyphenated identifiers (google-calendar, etc):
- klavis.ts: replace split('_')[0] prefix hack with direct findByToolName DB lookup
- ConnectorToolModel.findByToolName: query user_connector_tools by userId + toolName
P3 — queryByConnector adds userId filter:
- Prevents leaking tool metadata to wrong user if connector UUID is known
Tests — mock ConnectorModel/ConnectorToolModel in all execAgent test files:
- execAgent.builtinRuntime.test.ts
- execAgent.deviceToolPipeline.test.ts
- execAgent.disableTools.test.ts (queryAllByConnectorIds added to mock)
TypeScript — ConnectorDetail metadata.description:
- Use typeof === 'string' type guard to narrow unknown → string for JSX render
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 🔨 fix(connector): precise Klavis permission gate + update stale disabled comments
Klavis gate — identifier + toolName (precise, no same-name collision risk):
- CallKlavisToolParams: add identifier? field
- klavisExecutor: pass identifier to callKlavisTool
- callKlavisTool store action: thread identifier through to tRPC mutate
- klavis.callTool router: accept optional identifier in input schema
- Permission gate: when identifier present, do queryByIdentifiers + queryByConnector
+ find by toolName for a precise 2-field lookup; fall back to findByToolName for
legacy callers without identifier
Comments updated to reflect current disabled behavior:
- buildConnectorManifests.ts: disabled → injected with blocking description
- connector.ts schema: same correction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Local CLI hetero agents (claude-code, codex) only report `model` after
turn_metadata lands mid-stream. The previous `showUsage` check used the
broad `HETEROGENEOUS_TYPE_LABELS` lookup which matches both local and
remote types, so it returned true with an empty model. Usage then fell
through to the `ModelIcon` path (Usage uses the narrower
`isRemoteHeterogeneousType` for the brand-label branch) and rendered a
lone empty-model placeholder icon under the message.
Align the gate with Usage's internal branching: only bypass `!!model`
for remote hetero (openclaw, hermes) which never expose a real model id.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Background Agent Signal runs (memory / skill / self-reflection) execute under a
builtin agent slug. Two attribution gaps caused their traces to surface in the
wrong place:
- execAgent persisted the run's user + assistant message rows under the builtin
slug's agent id, while the operation row, isolated thread, and receipts all
attribute to the reviewed user agent on `marker.agentId`. The trace therefore
"hung" under the builtin reflection/skill agent. Persist messages under
`marker.agentId` when present, falling back to the executing agent otherwise.
- The memory run only created its isolated thread when an `assistantMessageId`
could be extracted from a `clientRuntimeComplete` source id
(`${assistantMessageId}:completion:${parentMessageId}`). Any other source left
it undefined, skipping thread creation so the memory-agent messages leaked
into the active conversation. Fall back to the triggering user `messageId` so
a child thread is still created.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(topic): add one-click collapse/expand all groups in topic sidebar
Add a toggle button in the topic sidebar header (next to Filter and the
more-actions menu) that collapses or expands all topic groups at once.
It reuses the existing `expandTopicGroupKeys` global status, so it stays
in sync with manual per-group toggling, and hides itself when there are
fewer than two groups (e.g. flat mode).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(topic): hide group toggle in flat mode
In flat mode, groupedTopicsForSidebar falls through to time grouping so
the computed group count can exceed one, but List renders FlatMode with
no accordion for the toggle to affect. Hide the control explicitly when
topicGroupMode === 'flat' instead of relying on the group count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 💄 style(topic): use 2-corner minimize/maximize icons for group toggle
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(task-detail): split task panel comment from topic-thread reply
CommentInput in TaskActivities stays as-is on canary — avatar + EditorCanvas
+ attachment + send button, posting a plain task-level comment.
TopicChatDrawer footer becomes a FeedbackInput that calls the in-scope
ConversationProvider's sendMessage, continuing the existing topic
conversation instead of attaching a comment + restarting the run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ✨ feat(task-detail): keep FeedbackInput visible while topic is running
Drop the canLeaveFeedback gate so the in-thread reply box renders even
when the topic is pending/running. ConversationStore.sendMessage already
queues messages during an in-flight stream, so this just exposes the
queue affordance to the user — letting them steer the next step
without waiting for the current run to terminate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 💄 style(task-detail): collapse FeedbackInput behind a follow-up button + add attach action
FeedbackInput now starts collapsed as a full-width "Send follow up message"
button. Click expands a ChatInput shell with EditorCanvas inside and a footer
that carries an AttachmentUploadButton on the left (+ icon) and the send
button on the right. Files are inserted inline into the editor (same
pattern as CommentInput) so they ride along on sendMessage's editorData.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 💄 style(task-detail): tighten CommentInput card & switch follow-up button to filled
- CommentInput card: padding-block 8px → 4px, editor placeholder fontSize 14px
- FeedbackInput collapsed button: default size + variant="filled" for a less
obtrusive look that sits flush in the chat footer
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 💄 style(task-detail): drop top padding above FeedbackInput in topic drawer
Use paddingBlock="0 12px" so the follow-up button hugs the last message
instead of floating with a 12px gap above.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 🐛 fix(task-detail): clear FeedbackInput editor before awaiting sendMessage
Previously the editor cleanup ran after the awaited sendMessage call, so
the box kept the just-sent text on screen until the entire send + stream
lifecycle resolved. Move clearContent / collapse before the await so the
input feels responsive (sendMessage already snapshots markdown and
editorData for its optimistic update).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* 🐛 fix(task-detail): keep FeedbackInput expanded after sending
Drop the setExpanded(false) call in handleSubmit so the ChatInput
remains open once the user has opened it. Collapsing it back to the
"Send follow up message" button right after every reply was disruptive
mid-conversation; the button only makes sense as the initial resting
state of the drawer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ✨ feat(chat): add forceRuntime override to SendMessageParams
Plumb a new optional forceRuntime field through SendMessageParams →
ConversationLifecycle.sendMessage → selectRuntimeType(parentRuntime).
parentRuntime already wins over every other signal in the dispatcher,
so callers can pin a send to 'gateway' / 'client' / 'hetero' regardless
of the agent's local/cloud config.
Also propagate forceRuntime through the message queue (QueuedMessage +
MergedQueuedMessage + mergeQueuedMessages + both drain sites in the
client and hetero executors) so a follow-up queued during an in-flight
run keeps its runtime pin when it eventually fires.
FeedbackInput in TopicChatDrawer passes forceRuntime: 'gateway' so
task-topic follow-ups stay on the server-side path that runTask
originally used, even if the user's global runtime preference is local.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* 🐛 fix(agent-documents): render system docs in editor
* ✨ feat(agent-documents): autosave highlight editor with safe unmount flush
Add debounced autosave to the non-markdown highlight editor and a StrictMode-safe
unmount flush via queueMicrotask, plus a beforeunload guard against dirty buffers.
* ✅ test: fix agent document PR type checks
* ✨ feat(task): auto-ensure qstash schedule
chore: cleanup code
chore: cleanup code
chore: cleanup code
* chore: migrate qstash init workflow to startServer
chore: migrate qstash init workflow to startServer
* fix: set default QSTASH_URL to eu region, same as SDK
fix: set default QSTASH_URL to eu region, same as SDK
Consume the `working_dirs` column: model `updateDevice`, tRPC `updateDevice`
input + `listDevices` output, and the client cwd pickers now operate on
`WorkingDirEntry[]` instead of the flat `recentCwds: string[]`.
- model / tRPC: `workingDirs` (input capped at 20, validated `{ path, repoType? }`)
- client `deviceCwd`: `nextRecentCwds` → `nextWorkingDirs`
- UI: DeviceWorkingDirectory / WorkingDirectory / DeviceDetailPanel / DeviceItem
render the detected repo type via the shared `renderDirIcon`
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🗑️ chore(opencode-go): remove MiMo V2 Omni and MiMo V2 Pro models
* ✨ feat(opencode-go): fetch model list from API with models.dev enrichment
- Try API /models first for real-time available models
- Enrich with models.dev data (pricing, abilities, SDK routing)
- Fallback to models.dev + model-bank if API fails
- Dynamic Anthropic SDK routing via provider.npm field
* 💰 fix(opencode-go): update MiMo pricing to match models.dev
- mimo-v2.5: input $0.14, output $0.28, cache_read $0.0028
- mimo-v2.5-pro: input $1.74, output $3.48, cache_read $0.0145
* ✨ feat(opencode-go): add MiniMax M3 and remove deprecated Qwen3.5 Plus
- Add minimax-m3: 512K context, vision support (image+video), 131K output,
pricing 0.6/2.4/0.12 USD per M tokens, released 2026-05-31
- Remove qwen3.5-plus: marked deprecated in models.dev
* 🐛 fix(opencode-go): restore Anthropic routing fallback when models.dev is unreachable
Codex P2 review on #15376:
- `routers` is called with `ClientOptions` (no `client` field), so
`options.client?.models.list?.()` silently returned `undefined` via
optional chaining; the `catch` never ran and `modelIds` stayed `[]`.
- In API + models.dev double-failure scenarios, `getAnthropicModels([])`
returned an empty list, regressing Anthropic SDK routing for MiniMax /
Qwen models.
Fix:
- Make `getAnthropicModels` self-contained: takes no parameters.
- Fallback chain: models.dev → static model-bank prefix match → `[]`.
- `routers` no longer touches `options.client`.
* ✨ feat(opencode-go): enrich model list with models.dev metadata
The model list pipeline previously forwarded only `{ id }` from the API
and models.dev, so displayName / pricing / context / modalities all came
from the static model-bank. When models.dev disagrees with model-bank
(e.g. a price update or new model), the runtime would show stale data.
Map models.dev fields into the flat shape that `processModelCard`
understands, so each card is enriched with:
- displayName (dev.name)
- contextWindowTokens / maxOutput (dev.limit)
- releasedAt (dev.release_date)
- functionCall / reasoning / vision / structuredOutput (dev.flags +
dev.modalities.input)
- pricing (dev.cost → flat input/output/cachedInput/writeCacheInput;
processModelCard's formatPricing converts it to units)
Fields models.dev doesn't have (description, organization, settings
.extendParams, etc.) still fall back to the model-bank entry via
processModelCard's knownModel lookup, keeping the static config as the
source of truth for UX-only fields.
* ✨ feat(opencode-go): drive reasoning_content handling from models.dev
The `reasoningInterleavedModels` list was hardcoded and drifted from
models.dev:
- Missing: kimi-k2.5, kimi-k2.6, mimo-v2-omni, mimo-v2-pro
- Stale: qwen3.7-max (no longer has `interleaved` in models.dev)
Move the source of truth into the models.dev cache. `fetchModelsDevData`
now also builds an `interleavedIds: Set<string>` from `m.interleaved.field`
alongside `anthropicModels`, so every derived field stays in sync with
a single fetch.
The new `getInterleavedModelIds` sync accessor lets `buildOpenAIPayload`
keep its sync signature; it returns the cached set when populated and
falls back to a hardcoded snapshot of the last-known models.dev state on
the very first chat request before any fetch has run.
🔨 chore(database): re-tighten getBuiltinAgent onConflict to the 0109 partial index
Now that migration 0109 has flipped agents_slug_user_id_unique to a partial
index (WHERE workspace_id IS NULL) in all environments, restore the precise
conflict arbiter { target: [slug, userId], where: isNull(workspaceId) } so
unexpected unique violations surface instead of being silently swallowed by the
bare onConflictDoNothing() transition form.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🗃️ db(database): migrate unique constraints to workspace scope (migration 0109)
Replace the legacy user-scoped UNIQUE constraints with workspace-scoped
partial unique indexes across agents, agent evals, agent skills,
documents, sessions, tasks, and rbac roles/user-roles. Adds migration
0109_migrate_unique_constraints and updates the affected schemas.
* 🐛 fix(database): match partial unique index in getBuiltinAgent upsert
Migration 0109 turned `agents_slug_user_id_unique` into a partial index
(WHERE workspace_id IS NULL). A plain `ON CONFLICT (slug, user_id)` no longer
matches it (Postgres 42P10), breaking getBuiltinAgent. Add the same predicate
via onConflictDoNothing's `where` option; builtin agents are always
workspace-less so the predicate always holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔨 chore(database): use bare onConflictDoNothing in getBuiltinAgent for 0109 transition
Index-shape-agnostic upsert so the builtin-agent path works whether
agents_slug_user_id_unique is the legacy full unique or the 0109 partial,
removing the deploy-ordering coupling. Re-tighten to { target, where } in a
follow-up once 0109 has flipped the index everywhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(gateway): add explicit type discriminator to tunneled tool calls
The device-gateway relays builtin local-system calls and tunneled stdio MCP
calls over one `tool-call` channel. The device was meant to tell them apart by
sniffing whether `toolCall.params` exists — fragile: any future builtin tool
that grows a `params` field would be misrouted to the MCP client.
Add an explicit `toolCall.type` discriminator (`'builtin' | 'mcp'`). The HTTP
client stamps it: `executeToolCall` → `'builtin'`, `executeMcpCall` → `'mcp'`.
The device routes on `type`, never on payload shape. Optional + back-compatible:
an older server that omits it is treated as `'builtin'`.
The desktop receiver switches to this discriminator in a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(desktop): execute tunneled stdio MCP calls from the gateway (#15470)
Receiving half of the gateway stdio-MCP work. When the cloud server tunnels a
stdio MCP tool call to this device (a `tool_call_request` carrying
`mcpParams`), run it locally instead of falling through to the builtin
local-system tool switch (which keys on apiName and has no MCP context, so it
rejected these as "not available on this device").
- `gatewayConnectionSrv`: add a dedicated `mcpCallHandler` + `setMcpCallHandler`;
`handleToolCallRequest` routes on the presence of `toolCall.mcpParams`,
sharing the existing response-envelope path.
- `GatewayConnectionCtr`: wire `setMcpCallHandler` → `executeMcpCall`, which
maps the wire payload to `McpCtr.runStdioMcpTool`.
- `McpCtr`: extract `runStdioMcpTool` core from the `callTool` IPC method so
both the renderer and the gateway tunnel share one stdio execution path
(no SuperJSON round-trip for the in-process caller).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🗃️ db(database): add workspace_id indexes (migration 0108)
Phase 3 of the workspace DB migration (LOBE-9961). Adds a btree index on
workspace_id to 70 tenant tables, plus 7 workspace-scoped partial unique
indexes (WHERE workspace_id IS NOT NULL) that pre-build the "new" side of the
Phase 4 (0109) unique-constraint cutover.
A separate production-safe runbook (0108_concurrent.sql, CREATE INDEX
CONCURRENTLY, ordered smallest->largest) is intentionally NOT committed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🗃️ db(database): make 0108 index migration idempotent
Add IF NOT EXISTS to all 70 CREATE INDEX + 7 CREATE UNIQUE INDEX statements,
per the db-migrations standard flow (defensive/idempotent SQL), matching how
0107 used DROP CONSTRAINT IF EXISTS. Safe to re-run and safe if the concurrent
runbook already built the indexes before the auto-migrator reaches 0108.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Stdio MCP servers live on the user's machine, but in gateway (cloud) mode
the agent runs server-side and `executeMCPTool` tried to spawn the stdio
binary on the cloud server — which has neither the binary nor access to the
user's machine, so local MCP tools (e.g. tasks calling a local kimi-datasource
MCP) always failed.
Add a dedicated `executeMcpCall` path that forwards the stdio connection
params (command/args/env) to a connected device, which spawns the MCP server
and runs the call locally. It rides the existing `/api/device/tool-call`
relay — the gateway forwards `toolCall` opaquely — so the device-gateway
worker needs no changes; the device routes on the presence of
`toolCall.mcpParams`.
Server-side only: when no device is connected, behavior is unchanged
(standalone Electron still spawns in-process). The desktop-side receiver that
runs the forwarded call lands in a follow-up.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🗃️ db(database): add workspace_id FK constraints (migration 0107)
Phase 2 of workspace_id rollout: add the FK constraint on the 70 tables
that gained a bare `workspace_id` column in Phase 1 (0106), referencing
workspaces(id) ON DELETE CASCADE.
- schema: add `.references(() => workspaces.id, { onDelete: 'cascade' })`
to all 70 nullable workspace_id columns
- 0107_add_workspace_id_fk.sql: idempotent drizzle migration
(DROP CONSTRAINT IF EXISTS + ADD), runs in CI / dev / self-host
- 0107_concurrent.sql: production-safe out-of-band runbook
(NOT VALID + VALIDATE) to avoid write-blocking locks on large tables;
NOT run by drizzle
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🔥 db(database): remove stray 0107_concurrent migration file
* 🐛 fix(database): break user/workspace schema circular dependency
Move userInstalledPlugins from user.ts into connector.ts to break the
user.ts <-> workspace.ts import cycle flagged by dpdm. connector.ts
already imports both users and workspaces, and consumers import the
table from the schemas barrel, so no call sites change.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(message): prefer dedicated usage column over metadata.usage
Token usage was promoted out of metadata.usage into a dedicated messages.usage
column, but nothing populated it and all reads still went through metadata.usage.
- Centralize write-side promotion in the DB model (update / updateMetadata /
create), so all executor callers populate the usage column from a top-level
usage payload, falling back to metadata.usage. metadata.usage stays dual-written
for backward-compatible reads.
- Reads prefer the usage column and fall back to metadata.usage: message queries,
getTokenHeatmaps, recomputeTopicUsage, the usage record service, and context
token accounting.
- Add top-level usage to UpdateMessageParams + DBMessageItem types.
- Mark metadata.usage and the legacy flat token fields as @deprecated, pointing
to the top-level usage field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(message): dual-write metadata.usage for top-level usage updates
When a caller passed the new top-level `usage` param without also sending
`metadata.usage`, the update wrote only `messages.usage` and left
`metadata.usage` stale/absent — legacy readers and rollback paths still consume
it during the dual-write transition. Fold the resolved usage into the metadata
patch so `metadata.usage` stays in sync regardless of how usage was passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🗃️ feat(database): add workspace_id columns to existing tables
Add a nullable `workspace_id text` column to user-owned business tables
(agents, sessions, topics, messages, files, tasks, RAG/eval, RBAC, devices,
connectors, etc.) so records can later be scoped to a workspace. Workspace
tables themselves already landed on canary via 0105_add_usage_agent_share_workspace.
Also folds in the additive device schema from #15356: the structured
`working_dirs` jsonb column + `WorkingDirEntry` type (recent_cwds kept,
now @deprecated).
Scope is deliberately column-only — the lowest-risk slice:
- migration 0106 is pure `ADD COLUMN IF NOT EXISTS` (metadata-only, ~ms locks
per table, online-safe, no app code change since columns are all NULL).
- FKs, btree indexes, and the per-user→workspace-scoped unique-constraint
conversions are intentionally deferred to follow-up PRs so each can use the
production-safe execution path Drizzle can't express (NOT VALID + VALIDATE,
CREATE INDEX CONCURRENTLY, atomic unique swap).
Scoping notes:
- devices / user_connectors / user_connector_tools: scoped (user-owned resources).
- push_tokens: left user/device-level — an Expo token is one per app install and
receives a person's notifications across all their workspaces.
- agent_shares: no workspace_id — scoped transitively via agent_id → agents.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 fix(database): satisfy inferred row types after adding workspace_id
Adding workspace_id made it a required key in the Drizzle-inferred row types
($inferSelect), breaking call sites that build those shapes by hand:
- rbac.getUserRoles: include workspace_id in the explicit select projection
- session action: add workspaceId to the constructed chat-group literal
- test mocks (apiKey / generation / generationBatch / generationTopic): add
workspaceId: null
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ✅ test(database): use toMatchObject for topic.create row assertions
The two `expect(createdTopic).toEqual({ ...full literal })` snapshots broke
on every new column (here: workspace_id). Switch them to toMatchObject so the
returned row may carry extra columns without churning the expected literal.
The dbTopic↔createdTopic strict comparisons are left as toEqual.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the self-iteration skill-management action off the inline policy
implementation onto an execAgent-dispatched builtin agent (slug
`skill-management`), mirroring the S3/S4 memoryWriter + self-iteration
migration. Adds the `agentSignalSkillManagement` serverRuntime, the
builtin-tool-agent-signal skill-management manifest/systemRole, and the
builtin-agents skill-management agent; strips the ~3.5k-line inline
skillManagement policy down to the dispatch shim.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Long-running queries (e.g. an insert stuck for 700s on lock contention)
could block indefinitely because Postgres' statement_timeout defaults to
0 (no limit) and neither the node nor neon pool configured one.
Add an optional DATABASE_STATEMENT_TIMEOUT env (milliseconds, no default)
applied to both NodePool and NeonPool as statement_timeout and
idle_in_transaction_session_timeout, so Postgres aborts a stuck statement
or idle transaction on the server side. Unset keeps the previous behavior.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ✨ feat(agent-management): paginate searchAgent with real totals and cap notice
The searchAgent tool silently clamped limit to 20 with no pagination and
reported totalCount as the returned page size, so models (and users) could
never discover agents beyond the 20 most recently updated ones.
- AgentModel: extract shared where builder, add countAgents (same
conditions as queryAgents)
- lambda router + client agent service: expose countAgents
- server tool runtime & AgentManagerRuntime: pass offset through, report
real totals (workspace + marketplace), emit explicit notes when the
requested limit is capped and when more pages exist, explain
out-of-range offsets instead of claiming no matches
- manifest: add offset param, document pagination
- agent-manager-runtime: add vitest config + test scripts (suite was
previously unrunnable), repair stale store mocks
* 👷 build(ci): wire 8 tested packages into the package test workflow
An audit found 8 packages carrying test:coverage scripts that were never
added to the CI PACKAGES allowlist, so their suites never ran:
- agent-gateway-client, device-gateway-client, device-identity,
eval-dataset-parser: already green, added as-is
- eval-rubric, fetch-sse: had no package-level vitest config, so vitest
fell back to the root config whose setup/aliases break outside src/ —
added minimal configs
- heterogeneous-agents: one assertion drifted (labels registry gained
amp/hermes/openclaw/opencode) with nobody noticing — updated
- agent-manager-runtime: wired in the previous commit
All 8 verified locally with the exact CI command
(bun run --filter <pkg> test:coverage).
* ✅ test(agent-management): cover searchAgent error path and market totalCount fallback
Codecov flagged 3 uncovered lines in the patch: the searchAgents catch
block (2 misses) and the totalCount ?? items.length fallback (1 partial).
Add the missing failure-path and fallback tests on both execution paths
(client AgentManagerRuntime + server tool runtime).
2026-06-04 10:52:25 +08:00
4767 changed files with 327345 additions and 55893 deletions
- Use stable ids and idempotency keys when the same source can arrive more than once.
- Preserve scope discipline. The runtime uses `scopeKey` to serialize related background work.
- Prefer the dedicated shared package types and builders from `@lobechat/agent-signal` for normalized nodes and result contracts.
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `src/server/services/agentSignal/**/__tests__` are the reference pattern.
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `apps/server/src/services/agentSignal/**/__tests__` are the reference pattern.
| **Full-stack** (new API + UI consuming it) | **Web** (browser + local dev server) | One surface where network requests and UI are observable together | [ui/web.md](./ui/web.md) |
| **Bot channels** (Discord / WeChat / Lark / …) | Native app via osascript / bridge | Only way to exercise the real channel end-to-end | `bot/<platform>/index.md` |
Escalate, don't duplicate: verify a backend change with the CLI first; only add
a UI pass when the change actually affects the UI.
### Environment support (local macOS vs cloud Linux)
The decisive constraint per surface is **how evidence (screenshots) is
captured**: CDP-based capture (`agent-browser screenshot`) renders from the
browser engine and needs no real display; OS-level capture (`screencapture`,
Generic reference for the `agent-browser` CLI — automate Chromium-based apps (Electron, Chrome, web) via Chrome DevTools Protocol. LobeHub-specific patterns live in [../ui/electron.md](../ui/electron.md) and [../ui/web.md](../ui/web.md); authentication recipes live in [auth.md](./auth.md).
Use `agent-browser` to automate Chromium-based apps via Chrome DevTools Protocol.
Install via `npm i -g agent-browser`, `brew install agent-browser`, or `cargo install agent-browser`. Run `agent-browser install` to download Chrome. Run `agent-browser upgrade` to update.
## Core Workflow
Every browser automation follows this pattern:
1.**Navigate**: `agent-browser open <url>`
2.**Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
3.**Interact**: Use refs to click, fill, select
4.**Re-snapshot**: After navigation or DOM changes, get fresh refs
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact).
## Essential Commands
```bash
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser
agent-browser close --all # Close all active sessions
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
agent-browser snapshot -s "#selector"# Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text"# Clear and type text
agent-browser type @e2 "text"# Type without clearing
echo"$PASSWORD"| agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
agent-browser auth login myapp
# Option 2: Session name (auto-save/restore cookies + localStorage)
agent-browser --session-name myapp open https://app.example.com/login
agent-browser close # State auto-saved
agent-browser --session-name myapp open https://app.example.com/dashboard # Auto-restored
# Option 3: Persistent profile
agent-browser --profile ~/.myapp open https://app.example.com/login
# Option 4: State file
agent-browser state save auth.json
agent-browser state load auth.json
```
### LobeHub dev server — inject better-auth cookie
`agent-browser --headed` on macOS can create an off-screen Chromium window, blocking manual login. For a local LobeHub dev server (e.g. `localhost:3010`), copy the `better-auth.session_token` cookie out of a **Network request** in the user's own Chrome DevTools and load it via `state load`. See [auth.md](./auth.md) for the full recipe.
## Semantic Locators (Alternative to Refs)
```bash
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find role button click --name "Submit"
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after clicking links/buttons that navigate, form submissions, or dynamic content loading.
## Annotated Screenshots (Vision Mode)
```bash
agent-browser screenshot --annotate
# Output includes the image path and a legend:
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
agent-browser click @e2 # Click using ref from annotated screenshot
```
## Parallel Sessions
```bash
agent-browser --session site1 open https://site-a.com
agent-browser --session site2 open https://site-b.com
| CLI | Seeded API key or OIDC Device Code Flow | `.records/env/agent-testing-cli.env` + `$HOME/.lobehub-dev` | No for seed path; yes for device-code fallback |
| Web | Seeded better-auth login or cookie copy | `~/.lobehub-agent-testing/web-state.json` + agent-browser session | No for seed path; copy cookie only as fallback |
| Electron | App's own login state | Electron user-data dir | Log in once manually in the app |
| Bot | Native apps (Discord/WeChat/…) logged in | Each app's own session | Once per app |
## CLI — Seeded API key
For the self-contained no-root-`.env` dev environment, seed the baseline user
`/api/auth/sign-in/email`, stores the returned cookie jar under
`~/.lobehub-agent-testing/`, converts it to Playwright `storageState`, loads it
into the `agent-browser` session, and verifies the session does not land on
`/signin`.
## Web — manual cookie injection fallback
`agent-browser --headed` on macOS often creates the Chromium window off-screen —
the user can't see or interact with it, so manual login inside the agent-browser
session fails. Instead, copy the **better-auth session cookie** out of the
user's own logged-in Chrome and inject it as a Playwright-style state file.
Do **not** use this on production URLs — only local dev. Treat the cookie as a
secret: don't paste it into shared logs, PRs, or commit it anywhere.
### Web — decision flow
1.`$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
3. If repo-root `.env` exists and `web-seed` fails, do **not** seed or modify the current DB; treat it as an existing local environment and use Cookie injection.
4. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
5. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:` → **Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
6.`pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
`ENABLE_MOCK_DEV_USER` is not Web auth. It only affects server-side API context
and does not satisfy Better Auth or stop the SPA from redirecting to `/signin`.
Do not use it as a substitute for `status --surface web` or Cookie injection.
### Using the authenticated session
```bash
agent-browser --session lobehub-dev open "$SERVER_URL/"
agent-browser --session lobehub-dev snapshot -i | head -20
```
### Notes
-`storageState` doesn't enforce the HttpOnly flag on load — the script stores
cookies with `httpOnly: false`, which is fine for local dev and sidesteps a
CDP-context quirk where HttpOnly cookies sometimes fail to attach.
- The state file is kept at `~/.lobehub-agent-testing/web-state.json` so
`setup-auth.sh status` can report web-auth readiness across sessions.
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| Script reports `no better-auth cookies found` | User pasted the wrong value, or the cookie parser regressed | Keep the raw `Cookie:` header as-is; run `scripts/setup-auth.test.sh` if the input looks valid |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
## Electron
The desktop app keeps its own persistent login state in its user-data
directory — log in once manually inside the app and it survives restarts of
`electron-dev.sh`. No injection needed. The standard check (do NOT hand-roll a
| 1 | Create a new page | pass | Title and body persisted after refresh |  |
| 2 | Respect requested length | fail | Requested about 600 Chinese characters; final body was about 1286 |  |
```
## Inline visual evidence
Screenshots and GIFs must be embedded so the report shows the image inline:
```markdown


```
Do **not** use these as the primary evidence for UI cases:
```markdown
[case 1 result](assets/case1-result.png)
assets/case1-result.png
file:///tmp/case1-result.png
```
Links are acceptable for non-visual artifacts such as CLI transcripts, HAR
files, or long logs. For videos, embed a representative screenshot/GIF inline in
the case row and link the full video as supplemental evidence.
Avoid the old wide table with separate `steps`, `expected`, and `actual`
columns unless the test is purely non-visual and truly needs that breakdown.
For UI reports, those columns make screenshot-backed reading harder. Put
procedural detail in the row's key observation only when it changes the
interpretation of the result.
Use an extra evidence/detail section only when the inline table cannot carry
the material cleanly, such as long CLI transcripts, HAR summaries, or multiple
screenshots for one case. In that situation, keep the table evidence cell as an
inline visual proof for UI cases or a concise link for non-visual artifacts,
then put the longer material under `Verification` or a brief
`Additional Evidence` section.
Status values: `pass` / `fail` / `blocked` (couldn't run — e.g. auth or env
missing; a blocked case is not a pass).
## result.json schema
```json
{
"branch": "feat/task-tree",
"cases": [
{
"id": "1",
"name": "task tree returns nested children",
"surface": "cli",
"status": "pass",
"evidence": ["assets/task-tree.txt"]
}
],
"commit": "abc1234",
"createdAt": "2026-06-11T15:30:00+08:00",
"summary": {
"total": 1,
"passed": 1,
"failed": 0,
"blocked": 0,
"score": 100,
"verdict": "pass"
},
"surfaces": ["cli"],
"title": "Verify task tree API"
}
```
`score` is optional — use it when the verdict has a subjective component (UI
polish, copy quality); omit it for purely binary runs. `verdict` is the single
word the user reads first: `pass`, `fail`, or `partial`.
## Rules
- **No evidence, no claim** — every `pass`/`fail` in the case table must link
at least one asset. UI cases must inline-embed their primary screenshot/GIF;
non-visual CLI/network cases may link transcripts, HAR files, or logs.
- **Screenshots must be visually verified** with the Read tool before being
cited.
- **Report failures faithfully** — a failing case with clear evidence is a good
report; a vague green one is not.
- If coverage was cut (cases skipped, surfaces not exercised), say so in the
Verdict section — silent truncation reads as "covered everything".
Default surface for verifying **pure frontend changes** (components, store logic, styles, interactions) in the primary product shape. Drives the Electron renderer over CDP with `agent-browser` — see [../references/agent-browser.md](../references/agent-browser.md) for the full command reference.
**Auth**: the Electron app keeps its own persistent login state — log in once manually in the app; sessions survive restarts. Run `../scripts/setup-auth.sh status` before testing (see [../references/auth.md](../references/auth.md)).
**Linux / headless (cloud)**: Electron itself runs on Linux, but it has no true headless mode — it needs a display server. In a headless environment wrap the launch with `xvfb-run` (virtual framebuffer). Everything CDP-based keeps working under Xvfb: the `agent-browser --cdp 9222` connection, snapshots, eval, and `agent-browser screenshot` (captured from the renderer via CDP, not the OS screen). What does NOT work on Linux: `capture-app-window.sh` (macOS `screencapture`), osascript, and the ffmpeg recording scripts in their current form.
### Setup / Teardown
Use the `electron-dev.sh` script to manage the Electron dev environment. It handles process lifecycle, waits for SPA readiness, and reliably kills all child processes (main + helpers + vite).
- **Always use `electron-dev.sh stop` to clean up** — `pkill -f "Electron"` only kills the main process; helper processes (GPU, renderer, network) survive. The script finds and kills all of them via PID matching against the project's electron binary path.
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently. The `electron-dev.sh` script handles this automatically.
- **Dev build auto-opens DevTools, which hijacks the CDP target** — `agent-browser --cdp 9222` may attach to the DevTools page (`devtools://…`) instead of the app (`app://renderer/`). Symptom: `get url` returns a `devtools://` URL. Fix: close the DevTools target and reconnect:
```bash
DT_ID=$(curl -s http://localhost:9222/json/list | python3 -c "import json,sys; ts=json.load(sys.stdin); print(next(t['id'] for t in ts if t['type']=='page' and t['url'].startswith('devtools://')))")
@@ -216,6 +216,6 @@ When using `--messages`, the output shows three sections (if context engine data
## Integration Points
- **Recording**: `src/server/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
- **Context engine capture**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
- **Recording**: `apps/server/src/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
- **Context engine capture**: `apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
- **Store**: `FileSnapshotStore` reads/writes to `.agent-tracing/` relative to `process.cwd()`
@@ -271,7 +271,7 @@ Lists in the same file you may need to touch:
-`defaultToolIds` — added to the agent's tool list by default
-`alwaysOnToolIds` — forced on regardless of user selection (use sparingly)
-`runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `src/server/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
-`runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `apps/server/src/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
- **If unreachable**: start the server:
```bash
# From cloud repo root
pnpm run dev:next
```
To **restart** (pick up server-side code changes):
```bash
lsof -ti:3011 | xargs kill
pnpm run dev:next
```
**Important:** Server-side code changes in the submodule (`lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
```bash
! cd lobehub/apps/cli &&LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
```
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
### Step 3: Test with CLI Commands
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
```bash
cd lobehub/apps/cli
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
```
### Step 4: Clean Up Test Data
Delete any test data created during verification:
```bash
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
-`apps/server/src/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
-`apps/server/src/routers/lambda/generation.ts` — status checking
-`packages/database/src/models/asyncTask.ts` — `AsyncTaskModel` including `checkTimeoutTasks`
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
Schema changes churn during feature development. When the schema changes before the migration has shipped, do not hand-edit the existing migration SQL to chase the new schema shape. Delete the draft migration artifacts added by this branch (SQL file, matching snapshot, and matching journal entry), then run the generator again and re-apply the normal migration review steps below.
For example, if this branch's draft migration is `0110_add_verify_tables_and_ai_infra_id`:
# 2. Remove the matching 0110 entry from the journal's "entries" array
# packages/database/migrations/meta/_journal.json
# 3. Regenerate from the current schema
bun run db:generate
```
This keeps the generated SQL, snapshot, and journal aligned with the actual schema. Manual SQL edits are reserved for review-time hardening such as idempotent clauses, custom extension SQL, and meaningful filename/tag updates.
Before release, if a feature branch accumulated multiple development-only migrations, consolidate them into one migration when possible. Production does not need to replay every intermediate draft shape, and fewer migrations reduce deploy-time risk.
For example, if this branch added `0110`, `0111`, and `0112`, delete all three drafts and regenerate a single migration:
```bash
# 1. Delete every draft SQL and snapshot this branch added
# 2. Remove the 0110/0111/0112 entries from the journal's "entries" array
# packages/database/migrations/meta/_journal.json
# 3. Regenerate one migration covering the full schema delta
bun run db:generate
```
Do not make a migration compatible with earlier development-only versions of the same branch. While the migration has not shipped, there is no production history to preserve. Fix local/dev databases directly with whatever SQL is simplest (drop the draft table, rename a column, delete draft rows), then regenerate the branch migration from the current schema.
For example, if an earlier draft on this branch created `signup_attempt_id` and you have since renamed it to `user_signup_log_id`, do not add a compatibility `ALTER ... RENAME` to the migration. Just fix the dev DB directly (see the `access-pg` skill for the `bun -e` + `pg` pattern), then regenerate:
```bash
# Fix the dev DB to match the new schema (simplest SQL wins)
set -a &&source .env &&set +a && bun -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query("ALTER TABLE user_signup_logs DROP COLUMN signup_attempt_id");
await client.end();
'
# Regenerate so the migration reflects only the final shape
bun run db:generate
```
After a migration has reached production or the target default branch, treat it as immutable: add a follow-up migration instead of rewriting it.
## Rebase conflicts
When a rebase conflicts in migration files, keep the upstream/default-branch migrations and remove all migrations introduced by the current feature branch. Complete the rebase, then regenerate this branch's migration from the rebased schema. This avoids merging two independent snapshots or hand-splicing journal entries.
@@ -38,7 +38,7 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
## Default Debug Order
1. Prove whether the raw CLI output is correct before touching UI code.
1. Prove whether the raw CLI output is correct before touching UI code. The app records every real session — read the most recent one via `cat .heerogeneous-tracing/.last-live-trace` rather than hand-rolling a `claude -p` repro (see references/debug-workflow\.md §2).
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
4. Turn the repro into a focused test before fixing.
@@ -77,6 +77,10 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
- Subagent tools show up in the main bubble:
check for subagent chunks reaching the main gateway handler.
- Wrong terminal-error guide (e.g. "usage limit reached" shown for a network drop):
a classifier is branching on a structured field whose mere presence isn't its meaning.
Grep the field across all event states in a real trace before trusting it — see
references/debug-workflow\.md §8 (CC `rate_limit_info` rides on `status: "allowed"` too).
Use `agent-browser` to automate Chromium-based apps via Chrome DevTools Protocol.
Install via `npm i -g agent-browser`, `brew install agent-browser`, or `cargo install agent-browser`. Run `agent-browser install` to download Chrome. Run `agent-browser upgrade` to update.
## Core Workflow
Every browser automation follows this pattern:
1.**Navigate**: `agent-browser open <url>`
2.**Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
3.**Interact**: Use refs to click, fill, select
4.**Re-snapshot**: After navigation or DOM changes, get fresh refs
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact).
## Essential Commands
```bash
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser
agent-browser close --all # Close all active sessions
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
agent-browser snapshot -s "#selector"# Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text"# Clear and type text
agent-browser type @e2 "text"# Type without clearing
echo"$PASSWORD"| agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
agent-browser auth login myapp
# Option 2: Session name (auto-save/restore cookies + localStorage)
agent-browser --session-name myapp open https://app.example.com/login
agent-browser close # State auto-saved
agent-browser --session-name myapp open https://app.example.com/dashboard # Auto-restored
# Option 3: Persistent profile
agent-browser --profile ~/.myapp open https://app.example.com/login
# Option 4: State file
agent-browser state save auth.json
agent-browser state load auth.json
```
### LobeHub dev server — inject better-auth cookie
`agent-browser --headed` on macOS can create an off-screen Chromium window, blocking manual login. For a local LobeHub dev server (e.g. `localhost:3011`), copy the `better-auth.session_token` cookie out of a **Network request** in the user's own Chrome DevTools and load it via `state load`. See [references/agent-browser-login.md](./references/agent-browser-login.md) for the full recipe.
## Semantic Locators (Alternative to Refs)
```bash
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find role button click --name "Submit"
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after clicking links/buttons that navigate, form submissions, or dynamic content loading.
## Annotated Screenshots (Vision Mode)
```bash
agent-browser screenshot --annotate
# Output includes the image path and a legend:
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
agent-browser click @e2 # Click using ref from annotated screenshot
```
## Parallel Sessions
```bash
agent-browser --session site1 open https://site-a.com
agent-browser --session site2 open https://site-b.com
agent-browser --cdp 9222 snapshot # Explicit CDP port
```
## iOS Simulator (Mobile Safari)
```bash
agent-browser device list
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
agent-browser -p ios snapshot -i
agent-browser -p ios tap @e1
agent-browser -p ios swipe up
agent-browser -p ios screenshot mobile.png
agent-browser -p ios close
```
## Observability Dashboard
```bash
agent-browser dashboard install
agent-browser dashboard start # Background server on port 4848
agent-browser dashboard stop
```
## Cloud Providers
Use `-p <provider>` to run against cloud browsers: `agentcore`, `browserbase`, `browserless`, `browseruse`, `kernel`.
## Browser Engine Selection
```bash
agent-browser --engine lightpanda open example.com # 10x faster, 10x less memory
```
## Electron (LobeHub Desktop)
### Setup / Teardown
Use the `electron-dev.sh` script to manage the Electron dev environment. It handles process lifecycle, waits for SPA readiness, and reliably kills all child processes (main + helpers + vite).
# Or auto-discover running Chrome with remote debugging
agent-browser --auto-connect snapshot -i
```
---
# Part 2: osascript (Native macOS App Bot Testing)
Use AppleScript via `osascript` to control native macOS desktop apps for bot testing. Works with any app that supports macOS Accessibility, no CDP or Chromium needed.
The pattern is the same for every platform:
1.**Activate** the app (`tell application "X" to activate`)
2.**Navigate** to a channel/chat (Quick Switcher `Cmd+K` or Search `Cmd+F`)
3.**Send** a message (clipboard paste `Cmd+V` + Enter)
4.**Wait** for the bot response
5.**Screenshot** for verification (`screencapture` + `Read` tool)
## Per-Platform References
Pick the file for your target platform — each contains activation, navigation, send-message, and verification snippets specific to that app:
Each channel has its own folder under `bot/<channel>/` containing an `index.md`
(activation, navigation, send-message, and verification snippets specific to
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [bot/osascript-common.md](./bot/osascript-common.md). Read this first if you're new to osascript automation.
## Bridge-based channels (no native app)
Some channels have no native app to drive with osascript — they connect through
a local bridge inside the Desktop app. These are tested with agent-browser
(IPC + UI) plus the bridge's own HTTP/REST endpoints, not osascript:
| `imessage/send-imessage-test.sh` | Send one real iMessage (desktop → BB → iMessage) and verify it sent |
### Window Screenshot Utility
`capture-app-window.sh` captures a screenshot of a specific app window using `screencapture -l <windowID>`. It uses Swift + CGWindowList to find the window by process name, so screenshots work correctly even when the window is on an external monitor or behind other windows.
Each script: activates the app, navigates to the channel/contact, pastes the message via clipboard, sends, waits, and takes a screenshot. Use the `Read` tool on the screenshot for visual verification.
### iMessage bridge regression script
`test-imessage-bridge.sh` does **not** follow the osascript bot interface — it
drives the Desktop bridge's IPC + HTTP layers and asserts the result, then
self-cleans. Needs BlueBubbles running and Electron up with CDP.
enforcement). See [bot/imessage/index.md](./bot/imessage/index.md)
for the full manual UI flow and known bugs.
---
# Screen Recording
Record automated demos using `record-app-screen.sh` (start/stop lifecycle, CDP screenshots + ffmpeg assembly). See [references/record-app-screen.md](references/record-app-screen.md) for full documentation.
Outputs to `.records/` directory (gitignored): `<name>.mp4` (video) + `<name>/` (screenshots every 3s).
---
# Gotchas
### agent-browser
- **Daemon can get stuck** — if commands hang, `agent-browser close --all` or `pkill -f agent-browser` to reset
- **HMR invalidates everything** — after code changes, refs break. Re-snapshot or restart
- **`snapshot -i` doesn't find contenteditable** — use `snapshot -i -C` for rich text editors
- **`fill` doesn't work on contenteditable** — use `type` for chat inputs
- **Screenshots go to `~/.agent-browser/tmp/screenshots/`** — read them with the `Read` tool
- **Dialogs block all commands** — if commands time out, check `agent-browser dialog status`
- **Default timeout is 25s** — override with `AGENT_BROWSER_DEFAULT_TIMEOUT` (ms) or use explicit waits
- **Shell quoting corrupts eval** — use `eval --stdin <<'EVALEOF'` for complex JS
### Electron-specific
- **Always use `electron-dev.sh stop` to clean up** — `pkill -f "Electron"` only kills the main process; helper processes (GPU, renderer, network) survive. The script finds and kills all of them via PID matching against the project's electron binary path.
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently. The `electron-dev.sh` script handles this automatically.
- **Don't resize the Electron window after load** — resizing triggers full SPA reload
- **Store is at `window.__LOBE_STORES`** not `window.__ZUSTAND_STORES__`
### osascript
See [bot/osascript-common.md](./bot/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
# Log `agent-browser` into a local LobeHub dev server
`agent-browser --headed` on macOS often creates the Chromium window off-screen — the user can't see or interact with it, so manual login inside the agent-browser session fails. Instead of sharing the user's real Chrome profile, copy the **better-auth session cookie** out of a request in DevTools and inject it into the agent-browser session as a Playwright-style state file.
## When to use
- You need `agent-browser` to reach an authenticated page on `http://localhost:<port>` (e.g. `localhost:3011`).
- The user already has a logged-in tab of the same dev server in their own Chrome.
- Spawning a headed Chromium to let the user log in manually is unreliable (window off-screen, no interaction).
Do **not** use this on production URLs — only local dev. Treat the cookie as a secret: don't paste it into shared logs, PRs, or commit it anywhere.
## Step 1 — Ask the user to copy the cookie from a Network request, NOT `document.cookie`
`document.cookie` will not return HttpOnly cookies, which is exactly where better-auth puts its session. Instruct the user:
1. Open the logged-in tab (`http://localhost:<port>/…`) in their own Chrome.
2.`Cmd+Option+I` → **Network** tab.
3. Refresh, click any same-origin request (e.g. the top-level document request).
4. In the right pane under **Request Headers**, right-click the `Cookie:` line → **Copy value** (or copy the entire header).
5. Paste the string into chat.
You only need the better-auth pieces. Everything else (Clerk, `LOBE_LOCALE`, HMR hash, theme vars) is noise and can stay. The minimum viable set is:
**Note on `httpOnly`**: the real cookie in the user's browser is HttpOnly, but `storageState` doesn't enforce the flag on load — it just attaches the value. Storing with `httpOnly: false` is fine for local dev and sidesteps a CDP-context quirk where HttpOnly cookies sometimes fail to attach.
## Step 3 — Load state and navigate
```bash
SESSION="my-test"# any stable session name
agent-browser --session "$SESSION" state load /tmp/state.json
agent-browser --session "$SESSION" open "http://localhost:3011/"
agent-browser --session "$SESSION" get url
# Expect NOT /signin?callbackUrl=… — if you still see signin, cookie didn't apply.
```
## Step 4 — Verify
```bash
agent-browser --session "$SESSION" snapshot -i | head -20
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
| Still redirects to `/signin` after `state load` | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| `state load` reports 0 cookies | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is; split on `"; "` |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-load |
| Domain mismatch | Use `domain: "localhost"` literally, no leading dot for local dev | — |
## Scope
Only covers authenticating an **agent-browser** session into a **local** LobeHub dev server. It does not:
- Work for production — production cookies are `Secure; HttpOnly; Domain=.lobehub.com` and must be delivered over HTTPS.
- Replace real OAuth flows — tests that must exercise the login UI need a real Chromium with `--remote-debugging-port` or a bot account.
- Flow cookies back to the user's Chrome — injection is one-way (into agent-browser only).
description: 'Backfill and maintain model-bank metadata (knowledgeCutoff, family, generation). Use when adding models, fixing cutoff/family data, running a metadata sweep across aiModels providers, or researching official knowledge cutoffs.'
user-invocable: false
---
# Model-Bank Metadata (knowledgeCutoff / family / generation)
How to populate and maintain the three structured metadata fields on `packages/model-bank/src/aiModels/*.ts` model cards, at single-model scale (new model PR) or repo-wide scale (sweep across \~80 provider files / \~1900 entries).
| `knowledgeCutoff` | `'YYYY-MM'` (or `'YYYY'` if only the year is published) | World-knowledge cutoff. When a vendor distinguishes a **"reliable knowledge cutoff"** from the broader training-data cutoff (Anthropic does), always use the **reliable** one. |
| `family` | lowercase slug (`claude`, `gpt`, `o-series`, `qwen`, `deepseek`, `llama`, `glm`, …) | Model lineage, finer than `organization`. Lets the UI group models and match the same model across aggregator providers. |
| `generation` | family slug + version (`claude-4.6`, `gpt-5.2`, `qwen3.5`, `llama-3.1`) | Generation within the family. Only set when confidently derivable from the model line's naming. Rolling aliases (`qwen-max`, `deepseek-chat`, `gemini-flash-latest`) get `family` only. |
All three are optional. **The cardinal rule: only fill what an authoritative source states or naming rules derive — never guess.** An empty field is correct for vendors that publish nothing.
No DB migration is ever needed for these: builtin models are merged from model-bank at read time (`repositories/aiInfra/index.ts` spreads the whole card), so new card fields flow to the client automatically.
- Official Hugging Face org model cards (huggingface.co/meta-llama/..., etc.)
- Official tech reports / system cards / launch blog posts
Reject:
- **Third-party aggregator sites** (aiknowledgecutoff.com and similar) — proven to copy one model's value across a whole family. A Cohere sweep once claimed `2024-06` for four distinct base models; none of the cited Cohere pages said that, and the only cutoff Cohere actually publishes is Feb 2023 for the 08-2024 Command R/R+ refresh.
- **AWS Bedrock model cards as sole source** — proven to conflate launch date with knowledge cutoff (DeepSeek R1's card lists both as "Jan 2025"). If Bedrock is the only place a value appears, leave the field empty.
- Inference from `releasedAt` — a release date is not a cutoff.
Variant inheritance: dated snapshots (`-2024-08-06`), speed/price tiers of the same checkpoint, quantizations (`-fp8`, `-awq`), context-length variants (`-32k`), ollama `:NNb` tags, and cloud-prefixed ids (`anthropic.`/`us.`/`global.` Bedrock ids) share their base model's cutoff. **Distills do not inherit** from teacher or base — use the distill's own published value or leave empty. **Sizes within one generation can genuinely differ**: Llama 3 8B is Mar 2023 while 70B is Dec 2023 (per Meta's own card) — don't "fix" that to one family-wide value.
Vendors that publish no cutoffs (leave empty, don't chase): Qwen, DeepSeek, GLM/Zhipu, ERNIE, Doubao, Hunyuan, SenseNova, Spark, MiniMax, StepFun, Yi (mostly), Moonshot.
Known per-vendor footguns:
- **Anthropic**: Opus 4.6 reliable cutoff is `2025-05`, Sonnet 4.6 is `2025-08` — easy to swap. Claude 3.7 is `2024-10` (system card: trained through Nov 2024, knowledge cutoff end of Oct 2024). Cite system cards / the models overview, not the Help Center article (a living page that drops retired models — citation rot).
- **xAI**: docs.x.ai has one blanket sentence covering grok-3/grok-4; mini variants are not named there. Grok 4.20/4.3 have no official cutoff anywhere.
- **OpenAI**: per-model docs pages (developers.openai.com/api/docs/models/<id>) state cutoffs explicitly, including snapshot differences (gpt-4-1106-preview `2023-04` vs gpt-4-0125-preview `2023-12`).
## family/generation derivation
Rule-based, no research needed: `scripts/derive-family.ts` holds the per-family regex rules. Traps already encoded there — keep them when extending:
- Date suffixes are not versions: `claude-sonnet-4-20250514` is generation `claude-4`, not `claude-4.2`.
- Size suffixes are not versions: `llama-3-8b` → `llama-3` (not `llama-3.8`); `gemma-7b-it` is **gemma-1** (not gemma-7).
- Fable/Mythos-class ids (`claude-fable-5`) don't match the opus/sonnet/haiku regex — they are the Mythos class — `family: 'claude-mythos'`, `generation: 'mythos-5'` (set manually; the launch page calls Fable 5 "the generally available Mythos-class model").
## Repo-wide sweep workflow
1.**Extract ids**: `bun .agents/skills/model-bank-metadata/scripts/extract-model-ids.ts` → unique normalized chat-model ids (normalization = last path segment, lowercased). Non-chat types (image/video/embedding/tts) have no knowledge cutoff — skip them.
2.**Research (multi-agent)**: chunk ids by family (≤50 per chunk) and fan out one research agent per chunk (Workflow tool), each returning `{id, cutoff, source}` with the sourcing rules above baked into the prompt, **plus** one adversarial verify agent per chunk that re-fetches cited sources and refutes unsupported claims. The verify pass is load-bearing: it caught the Cohere aggregator copy-paste and the AWS launch-date conflation.
3.**Policy filter**: before applying, drop entries whose only source is a rejected category (check the returned `sources` map — e.g. drop everything sourced to aws.amazon.com).
4.**Apply**: `bun scripts/apply-cutoffs.ts <map.json>` and `bun scripts/apply-family.ts <map.json>` (run from repo root). Both are idempotent codemods keyed on normalized id — aggregator providers get the same values automatically; entries that already have the field are skipped. They rely on the uniform prettier formatting of the data files (entries start ` {` / end ` },`, fields at 4-space indent).
- **New model PRs** should fill all three fields inline, citing the official source in the PR body (see the Anthropic entries in `anthropic.ts` for reference values).
- **After resolving merge conflicts** in model-bank data files, sanity-check that metadata didn't vanish: `git grep -c knowledgeCutoff -- 'packages/model-bank/src/aiModels/*.ts'` before vs after. A three-way stack of model PRs once silently dropped all 10 Anthropic cutoffs during conflict resolution.
- Dirty ids exist in aggregator data (a sambanova id once carried a trailing tab). The codemods match ids verbatim — if a map key won't apply, check for invisible characters before assuming the model is missing.
@@ -53,6 +53,12 @@ For Modal specifically, see the dedicated **modal** skill — use the imperative
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Menu, SideNav, Tabs |
## Loading indicators
**Do NOT use antd `Spin` / `<Spin />`.** Use a project loader
(`NeuralNetworkLoading`, `DotsLoading`, …) — see the **ux** skill ("Loading
visuals") for the component table and when to use each.
## State
When a feature component manages more than 3 pieces of state (`useState`/`useReducer`/derived state), extract the logic into a custom hook (e.g. `useXxx`). Keep the component focused on rendering — the hook holds state and handlers, so logic can be unit-tested without rendering the component.
@@ -18,8 +18,8 @@ Periodic review of the project-local skill set under `.agents/skills/`. The goal
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
```bash
find .agents/skills -name SKILL.md | wc -l # total count
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
find -L .agents/skills -name SKILL.md | wc -l # total count, including symlinked skills
find -L .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length, including symlinked skills
```
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
@@ -50,7 +50,7 @@ Common false positives (do NOT merge):
- `db-migrations` vs `drizzle` — distinct workflows (migration files vs schema authoring).
- `microcopy` vs `i18n` — content vs mechanics.
- `agent-runtime-hooks` vs `agent-tracing` vs `agent-signal` — different surfaces of the agent system.
- `testing` vs `local-testing` vs `cli-backend-testing` — different test types.
- `testing` vs `agent-testing` — different test types.
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
# Database package (server)
# Database package (server-db, Postgres — BM25/pgvector parity, what CI measures coverage in)
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' '[file]'
```
**Never run** `bun run test` - it runs all 3000+ tests (\~10 minutes).
> **Database models/repositories:** every new file under `packages/database/src/models/**`
> or `src/repositories/**` ships with a sibling `__tests__/<name>.test.ts` in the same PR.
> Use the real DB via `getTestDB()` (integration style), guard BM25/full-text-search blocks
> with `describe.skipIf(!isServerDB)`, and always test user-isolation. See
> `references/db-model-test.md` for setup, schema gotchas, and the client-vs-server-db split.
## Test Categories
| Category | Location | Config |
@@ -37,6 +43,9 @@ cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only'
2. **Tests must pass type check** - Run `bun run type-check` after writing tests
3. **After 1-2 failed fix attempts, stop and ask for help**
4. **Test behavior, not implementation details**
5. **Regression tests for bug fixes** - After fixing a bug, add a regression test that fails before the fix and passes after, to prevent recurrence
6. **No new component tests** - Only update existing React component tests. Complex logic should be extracted into hooks and tested there instead
7. **All source changes before any test changes** - Complete all source file edits first, then update tests in a separate pass. Interleaving disrupts reasoning about the source changes, especially across many files
| **client-db** (default) | PGlite (in-memory) | `bunx vitest run` | Migration runner **skips any SQL containing `pg_search` / `bm25`** — the ParadeDB BM25 `@@@` operator does not exist here. |
| **server-db** | node-postgres → `DATABASE_TEST_URL` | `TEST_SERVER_DB=1` | CI uses the `paradedb/paradedb` image (has `pg_search`). **Coverage is measured in this mode** (`test:coverage` → `vitest.config.server.mts`, uploaded to Codecov). |
```bash
# 1. Client environment (fast)
cd packages/database && TEST_SERVER_DB=0 bunx vitest run --silent='passed-only' '[file]'
# 1. Client environment (fast, default — what most local runs use)
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
description: 'TRPC router development guide. Use when creating or modifying src/server/routers, adding procedures, or implementing server-side API endpoints.'
description: 'TRPC router development guide. Use when creating or modifying apps/server/src/routers, adding procedures, or implementing server-side API endpoints.'
description: 'LobeHub product design values / principles / checklists. Load this skill whenever the work touches user-interface features or implementation — designing or building any user-facing flow — to get better UX results.'
user-invocable: false
---
# UX — Design Values & Execution Checklists
How LobeHub products should feel, and concrete rules to get there. Use this when
**building or reviewing** any user-facing flow. For component/styling choices see
**react**, for wording see **microcopy**, for imperative modal wiring see **modal**.
## Design values
LobeHub follows four product design values — **Natural・Meaningful・Certainty・
capabilities as the user gets there, don't dump everything at once. _(Growth・Natural)_
- [ ] **Surface related actions at the moment of need** — make the next capability
discoverable in context (e.g. after the first item exists, offer what to do
with it), not buried in a far-off menu. _(Growth・Meaningful)_
---
## Quick review checklist
**Read — viewing data & lists**
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA. Always-rendered chrome (toolbar/header) still gets a body empty state.
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
- [ ] Capped/scrollable/virtualized list scrolls the restored active item into view on mount (`block: 'nearest'`, re-run after async rows mount).
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
- [ ] Multi-tab/view surface lands on the tab the entry intent implies (and falls back to a populated view, decided from resolved state); a manual pick sticks.
**Edit — entering & changing content**
- [ ] Editors back up in-progress input locally and recover it after refresh/crash/failed-save; destructive exits warn, never silently discard.
**Act — operations, flows & buttons**
- [ ] Action leads the user forward; success offers a primary "go to result".
- [ ] Bulk action has a single-item entry (and vice versa).
The migration owner is responsible for rollout follow-up and incident handling for this schema change.
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or from commit metadata. Do not hardcode a username.
> \[!NOTE]: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or from commit metadata. Do not hardcode a username.
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
> \[!NOTE]: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
When a slice doesn't write local state at the moment — e.g. it reads context
from `#get()` and forwards calls to another store, or just runs hooks — drop
the `#set` field. Otherwise ESLint's `no-unused-vars` flags the unused private
field.
Mark the constructor's `set` param as `_set` and `void _set` it to keep the
`(set, get, api)` shape aligned with `StateCreator`. This is **a snapshot of
the current need, not a permanent contract** — if a later change needs `set`,
restore the `#set` field and use it; do not invent a workaround to keep the
"unused" form.
When a slice doesn't write local state (e.g. it delegates to another store or just runs hooks), drop `#set` and mark the constructor param as `_set` with `void _set` to keep the `(set, get, api)` shape:
Due to a change in the workflow file of the [LobeChat][lobechat] upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork. Please refer to the detailed [Tutorial][tutorial-en-US] for instructions.
@@ -115,14 +115,19 @@ cd packages/database && bunx vitest run --silent='passed-only' '[file]'
```
- Prefer `vi.spyOn` over `vi.mock`
- Tests must pass type check: `bun run type-check`
- After 2 failed fix attempts, stop and ask for help
### Type Checking
```bash
bun run type-check
```
### i18n
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
- `pnpm i18n`is slow; run it manually when locale keys need updating (e.g. before opening a PR).
- Ship en-US and zh-CN by hand in the same PR: write the English source in `src/locales/default/*.ts` and mirror it to `locales/en-US/`; hand-translate `locales/zh-CN/`. Leave all other locales to CI.
- Don't run`pnpm i18n`manually by default — a daily CI workflow (`auto-i18n.yml`) runs it and opens an automated translation PR for any missing keys.
- Run `pnpm i18n` manually only when your branch needs the translated locales immediately, instead of waiting for the daily job (slow; requires `OPENAI_API_KEY`). Note it only fills keys missing from other locales — value-only edits never need it.
### Code Style
@@ -131,3 +136,5 @@ cd packages/database && bunx vitest run --silent='passed-only' '[file]'
### Code Review
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
When designing or reviewing user-facing flows (empty/loading/error states, confirmations, async feedback, button hierarchy, lists at scale, pickers), follow the **ux** skill (`.agents/skills/ux/SKILL.md`) — LobeHub's design values (自然 / 意义感 / 确定性) plus per-aspect execution checklists.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.