`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>