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>