Commit Graph

1542 Commits

Author SHA1 Message Date
Arvin Xu e13ad41733 ✨ feat(server): serve tool results as render-facing view models on the v2 read path (#19666)
* ✨ feat(server): add a tool view-model layer to the UI message read path

Tool messages carry one blob that serves two audiences: the model replays the
whole thing into the LLM context, while the screen picks a handful of fields out
of it. Measured on production, the screen's share is tiny — a crawlSinglePage
render reads title/url/description and `content.length`, and never the 37 kB page
body sitting next to them.

This adds the seam that lets the two be separated: a pure, dependency-free
projector per `identifier/apiName`, applied over `UIChatMessage[]` on the way
out of `MessageService.queryMessages`.

The split is a layer boundary rather than a query flag. The UI read path goes
through the service; the LLM context read calls `MessageModel.query` directly and
so cannot reach a projector even by accident — no caller has a parameter to
forget.

The registry ships empty, so this commit changes no bytes on the wire. Projectors
land per tool in follow-ups; a tool without one keeps today's behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(tools): project the four heaviest tool payloads to what the UI renders

Registers projectors for the tools production measurements put at the top of the
tool payload, and moves the surfaces that need the stored bytes onto an
on-demand fetch.

What each projector keeps, from an audit of the components that consume it:

- crawlSinglePage / crawlMultiPages — the card reads title, url, description,
  crawler and `content.length`; the 37 kB page body it never touches becomes a
  200-char preview, and the tool message body (a second copy, for the model)
  goes entirely. `length` is pinned before truncating so the character count
  stays honest.
- runCommand — the output is stored three times (message body, `state.stdout`,
  legacy `state.output`) and the render reads one. The other two go. The
  surviving copy is NOT truncated; it renders inline in a scrollable block.
- readDocument — the only surface is an inspector chip showing `state.title`.
  `state.content` and `state.xml`, two representations of the same document,
  go with the body.

Nothing is lost, only moved. `message.getToolResultPayload` returns the stored
content and plugin state for one message, and three surfaces call it when they
are opened — never on mount, which would pull everything back down on load:

- the crawl detail portal, when its local body is shorter than the pinned length
- the raw/debug panel, which only mounts once toggled on
- the fallback renderer, behind an explicit control, since an empty body there
  is ambiguous between "returned nothing" and "projected away"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(server): only project tool payloads for the WebSocket v2 cohort

The projected read path was applied to every caller of
`MessageService.queryMessages`, on the assumption that the LLM context is
assembled server-side and so can never see it. That holds on the server — the
context read goes through `MessageModel.query` directly — but not in the
browser: a client-mode run builds its context from `dbMessagesMap`
(`executeClientAgent({ messages: displayMessages })`), which is filled by the
very read being projected. Tool results would have gone missing from the
model's context, silently.

Gate on the `gatewayMux` lab opt-in, read server-side from `users.preference`.
That cohort is exactly the one whose runs always execute server-side, so the
browser there never needs the stored payload for anything but rendering. The
preference is memoized per service instance — a gateway run queries once per
step — and any failure to read it falls back to the whole payload.

Turning the lab off does not invalidate already-cached projected lists. Left
alone on purpose: the mux is on its way to being the only runtime, and the gate
goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(server): project the conversation read, and name the load control

Two defects a live run surfaced that the unit tests could not.

`message.getMessages` builds its own `MessageModel` — it passes different query
options than `MessageService.queryMessages` — so the conversation load never
reached the projector at all. Measured against a seeded topic, the response was
byte-identical to the unprojected one (193,619 bytes) while the WS snapshot path
was already projecting. The projection step is now public on the service and the
router applies it explicitly; the shared-topic branch stays unprojected, since an
anonymous visitor has no authenticated way to fetch the stored payload back.

The fallback renderer's on-demand control reused `debug.response`, so it rendered
as a button labelled "Response" — a section heading, not an action, giving no
hint that it loads what the read path left behind. It gets its own key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 📝 docs(acceptance): record two worktree bootstrap traps

L-S23: a hand-built node_modules symlink farm passes vitest and tsgo but cannot
start the dev server, and a fresh install cannot be the fallback in a repo that
commits no lockfile. L-S24: the dev port is allocated per run and `/` answers 302
to /signin, so a remembered port and a 200 predicate both read as "never came up".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 style(chat): load a projected tool result on expand, not on a button

Expanding a tool row is already the user asking to see its result, so a second
click to fetch what the read path left behind is a step that earns nothing. The
fallback renderer now fetches as it mounts and shows a skeleton under the
Response heading until the body lands.

Mounting is the right trigger rather than a render-time effect: this component
is the Accordion item's child and only mounts once the row opens. Verified in
the app — expanding the assistant group fires no request, expanding one row
fires exactly one.

The control's dedicated string goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(tools): move the command output out of the conversation too

Review asked why `runCommand` still shipped ~10 kB of state after projection.
Because the projector kept `stdout`: the card renders the output inline, so it
looked like the card needed it up front. It doesn't — the card lives inside the
row's detail, which only mounts when the row is expanded, the same property that
made load-on-expand safe for the fallback renderer.

So the output goes with the other two copies, and what stays is the settled
metadata the collapsed row reads: exit code, success, background flag, command
id, stderr, produced files. Measured on the same seeded topic, that state went
from 10,040 to 112 bytes and the whole response from 17,494 to 7,580 — 25.5x
against the unprojected 193,619.

Which surface has to fetch the payload back is now something the projector
declares (`storedPayloadNeededBy`), carried to the client on the existing
`payloadOmitted` field as `'detail' | 'render'`, rather than a second registry
the projectors could drift from. `ToolRender` hydrates the `'render'` tools as
it mounts; `'detail'` tools keep a self-sufficient card and only their detail
portal fetches. Measured per step: loading the conversation, expanding the
assistant group, and expanding the crawl row each fetch nothing; expanding the
command row and the document row fetch exactly once each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(server): stop pushing the whole AgentState on agent_runtime_init

`agent_runtime_init` shipped the raw `initialState` to every non-visitor
subscriber: the entire `AgentState`, which carries the LLM context in `messages`
plus the tool-set maps that `stripStateForStream` already treats as the size
problem on `finalState`. It reached the wire unstripped because
`publishAgentRuntimeInit` pushes directly rather than through
`publishStreamEvent`, and because the existing chokepoint only looks for a
`finalState` key.

Nothing reads it. The web handler logs the event and breaks, the CLI prints a
fixed line, and the gateway transport does not handle the type at all. Shared-
agent visitors have been receiving `{ status }` alone for exactly this reason;
every run now gets the same shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(server): close three gaps Codex review found in the projected read path

**Recovery RPC skipped the conversation guard.** `getToolResultPayload` returned
a tool's stored content and state after only the model's ownership scoping,
which in a workspace is workspace-wide. A member holding a message id — including
one kept after access was revoked — could read output from a conversation they
cannot open. It now runs `assertCanViewMessageTargets` first, the same way the
list read guards with `assertCanUseTopicTargets`.

**Share visitors could never recover a projected payload.** `queryUiMessages`
runs under the CREATOR's identity, so the gate read the creator's lab preference
and pushed the visitor a projected snapshot; the recovery RPC then runs as the
VISITOR against ownership-scoped reads that cannot see a creator-owned row, so
the crawl portal, the raw viewer and the fallback had no way to fill it back in.
Visitor snapshots now keep their payloads whole.

**A projected tool read as still running.** The completion test was
`!!result.content`, which an emptied body fails, so while the assistant message
was busy a finished tool was swapped back to its loading placeholder. The stored
length now rides `ChatToolResult` and one shared `hasToolResultBody` answers the
question for both the group's completion check and the status indicator — the
acceptance round missed this because its fixture was a settled conversation.

Codex's fourth finding no longer applies: it described the projector deleting
`output` while keeping an empty `stdout`, and that branch is gone — both keys
are dropped together now and the card hydrates on expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✅ test(server): cover tool projection options in runtime snapshots

* ♻️ refactor: slim ws2 step message transport

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:40:09 +08:00
Arvin Xu 7334cca4b9 ♻️ refactor(mecha): assemble the run's skill pool in the shared core (#19667)
* ♻️ refactor(mecha): assemble the run's skill pool in the shared core

`assembleSkillPool(sources, options)` owns the candidate pool
`<available_skills>` is built from and `activateSkill` resolves against:
precedence project > db > agent-skills > builtin on a name collision,
the disabled drop, the share allowlist, and the device-only builtin gate.
`isBuiltinSkillEnabled` moves with it; `src/helpers/skillFilters`
re-exports it with the client's desktop default.

The browser supplies two of the four sources (it scans no project
directory and mounts no agent-document bundles) and, per the alignment
review, stops narrowing the listing in manual skill mode: manual
withholds the discovery tools, it does not hide what exists — the server
semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(mecha): keep unselected skills out of the pool in manual mode

Manual activation mode promises "only user-selected tools and skills are
available to AI", but withholding the two discovery tools does not deliver
it: `activateSkill` ships with the always-on `lobe-skills` tool and
resolves against the skill pool. The shared pool now collapses the
selectable sources to the run's enabled plugin ids in manual mode, so an
unselected skill is neither listed nor activatable. Project and device
skills come from the working directory and can never be picked in the UI,
so the mode does not speak about them and they stay.

This also closes the same hole on the server, which never narrowed at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(mecha): keep the builtin skill gate out of the package barrel

`src/helpers/skillFilters` is used by store slices and settings UI. Importing
the gate from the package root pulled the whole agent-runtime graph in behind
it, and a settings test that partially mocks `@lobechat/const` broke on a
manifest it never meant to load. The gate moves to its own leaf module with a
`@lobechat/mecha/builtinSkillGate` subpath export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 12:11:25 +08:00
Arvin Xu 8a8b999993 ✨ feat(expertise): distil delivery standards from rejected acceptance checks, and restate them as they repeat (#19629)
* ✨ feat(expertise): distil delivery standards from the checks a reviewer rejected

The reviewer's rejections are the only structured record of what they actually
require, and nothing read them: expertise ingestion mined conversations only.
This turns a settled acceptance round into expertise the same way a topic
completion already does, reusing the whole lesson dedup / hit / snapshot path.

Trigger: a new round attaching to the acceptance settles the previous one. The
product's reject flow ends at a clipboard copy (`onCopyReview`), so no
server-side event marks "I finished reviewing" — but the next round landing
proves the previous round's judgement is final. Folding into a draft round is
deliberately not a trigger: no new round opened, so nothing settled.

- `persistDomainRun` now takes an explicit run descriptor instead of assuming a
  topic/agent, so both ingestion paths share it.
- One observation writes one hit per rejection it cites, so `hitCount` counts
  violations rather than analysis passes.
- The prompt is a separate chain: it must generalize ("this divider isn't
  needed" → "decorative dividers are not added"), cite its sources, and state
  when the standard does not apply. Restating one rejection verbatim yields a
  rule that never fires again, which is the failure this pipeline exists to
  avoid.
- Domains resolve through the project the acceptance belongs to, falling back to
  the owner's always-on domains; when neither exists, a delivery-standards
  domain is created, owned by the user and merely mounted on the project so a
  sibling project can mount the same one later.

Distillation runs out of band and never throws into the attach path: losing a
lesson is cheaper than losing the reviewer's round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(expertise): read the circled frames, and stop losing rounds to `{}` nulls

Replayed one real round (7 rejections, all circled) against the pinned model and
found two things unit tests cannot see, because a unit test hands the parser a
constructed object while the model hands it whatever it feels like.

1. Every `['string', 'null']` field came back as `{}` under a strict json_schema,
   so `RejectionAnalysisSchema.parse` threw and the entire round was lost. The
   nullable fields are plain strings now, with "" as the "none" the service maps
   back to null. A test walks the schema and fails on any resurrected union.

2. The distillation was text-only, which is close to useless here: 616 of this
   owner's 876 rejections circle a region on a screenshot, and "this isn't
   aligned" / "this cyan is too light" cannot be understood from the words. One
   round's frames are now inlined as data URIs, circled ones first, and each
   circled region is rendered with its label and normalized position so the
   prompt can point at the frame it belongs to.

   Inlined rather than linked because a link makes the provider fetch from our
   storage, which is unreachable for a local or private bucket. Frames are capped
   and the overflow is named in the prompt, so a withheld frame is never read as
   proof that nothing was wrong there.

Also makes `limits` mandatory prose rather than an optional null. On the first
replay the model left it null on all 7 observations; a standard with no stated
exemption gets applied everywhere and becomes noise.

Verified on the replayed round: parse clean, all 7 limits filled with real
exemptions, and re-feeding the same rejections with those 7 lessons present
attaches 7/7 with 0 new lessons — the dedup path holds, so repeats do not
fragment the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): keep a distilled standard to what the reviewer actually said

Reviewing the first replay's output against the rejections it came from showed
the distillation inventing the two things that matter most.

`reasoning` was manufacturing design rationale. "This order is reversed" became
"follows common messaging interaction conventions and does not match user visual
habits" — plausible prose the reviewer never wrote, which a later compile step
would then enforce as if they had. It now carries the reviewer's own words, or
says outright that they gave no reason.

`limits` was inventing exemptions. "Except on mobile or narrow containers",
"except in large rich-text editors" — none of it stated by anyone. An invented
exemption silently narrows a standard the reviewer stated without limit, so the
field is now filled only from a boundary they drew or a contradicting rejection
in the same round, and otherwise says so.

Generalization also gets a test it can fail rather than the vague "strip the
screen names": could a delivery on a completely unrelated screen violate this?
A new `subject` field forces the answer to be written down — a model that cannot
say what the concrete thing is an example of has not generalized at all. The
prompt also caps the climb at what was actually objected to, so one rejected
divider does not become a rule about every visual element.

Replayed the same production round (7 rejections, all circled) on v2: reasons
are the reviewer's own words on all 7, limits honestly empty on all 7, and the
abstraction climbs where it should — "the annotation border on a screenshot"
became "highlight and annotation lines drawn over content".

Known cost, not yet addressed: climbing a level can drop the direction out of a
standard. "Place the add-reaction control after the existing reactions" became
"maintain the correct order", which is no longer something a delivery can be
judged against. Two of seven regressed this way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): explain why a standard holds, and say who supplied the reason

The previous pass cut `reasoning` down to the reviewer's own words to stop the
distillation inventing rationale. That over-corrected: quoting the complaint back
carries no information, duplicates the evidence stored on the hit, and strips out
the one thing that lets a standard reach a screen nobody has built yet — the
mechanism. "A pale line disappears against a white background, so the reader
cannot tell which region was circled" is what makes the rule portable; "the
reviewer said the cyan is too light" is not.

So the reason is explanatory again, and the real hazard is handled where it
belongs: `reasonSource` records whether the reviewer stated it or the
distillation supplied it. The compile step downstream turns a lesson's reason
into an enforced criterion, so a reason the model invented must never read as
something the reviewer said.

That field only works if it is decided by an observable rather than a judgement
call. Asked "did the reviewer say why", the model marked 3 of 7 as `reviewer` on
rejections that were pure instructions — "put it in one row, annotations left,
actions right", "there's an extra line here" — which is exactly the direction of
error the field exists to prevent. Asked instead "does their text state a
consequence or a cause", all 7 came back correct on replay: only "the cyan is too
light, I can't see it" kept `reviewer`, because it names the consequence.

Inferred reasons are marked in the lesson body so the distinction survives into
the UI, and the prompt now says to answer `inferred` when in doubt: under-claiming
costs nothing, over-claiming puts words in the reviewer's mouth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): judge a distilled reason by whether it settles a case nobody described

"The mechanism is what lets a rule transfer to a new situation" is not just
guidance — it is a test the model can run on its own sentence, so the prompt now
states it that way: could someone facing a case this round never described settle
it using your reason alone?

That test is what separates a mechanism from the objection restated in nicer
words. "Harms visual tidiness", "feels inelegant", "adds cognitive load" all fail
it, because tidiness and elegance are the judgement under review — they settle
nothing. The prompt names those phrasings and asks for the observable underneath:
what does a person fail to see, misread, mis-click, or have to do twice.

Replayed on the same production round, 5 of 7 reasons moved from verdict to
mechanism: "loses the value of an inline screenshot" became "the reader cannot
make out the content and must click to enlarge every time"; "misleads the user
into thinking it is a network fault" gained the action that follows, "and retries
blindly".

Two did not, and the reason is worth recording rather than tuning away: a purely
aesthetic standard has no functional mechanism underneath it. Pressed for one,
the model can only produce a synonym of the verdict ("visual noise" for
"untidy"). Those lessons want an honest "this is the owner's taste" more than
they want an invented mechanism — a distinction worth making explicit once there
is enough volume to see how common they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): let a standard admit it is taste rather than fake a mechanism

Not every standard has a mechanism underneath it. Some of what a reviewer
requires is simply what they prefer, and pressing the distillation for a reason
anyway produces a synonym of the objection — "visual noise" for "untidy",
"fragments related actions" for "I want it in one row". Those read as objective
and would be enforced as if they were, which is worse than an admitted
preference.

`reasonKind` records which one a standard is, orthogonal to `reasonSource`: one
says who supplied the reason, the other says whether the reason rests on
something observable. The model's own failure is the detector — if the only
reason it can produce is a synonym of the complaint, that is the signal to stop
and answer "taste". A taste reason still has to be actionable: "the owner does
not accept dividers that were not asked for; regions are separated by spacing
and container edges alone" says what to do next without pretending to be
physics.

It is also load bearing downstream. A taste standard has no objective test, so
the compile step must not turn it into a criterion that blocks a delivery on its
own — noted where the field is declared, since that step lands later.

Replayed on the same production round: 4 mechanism / 3 taste, and the three are
exactly the ones that had been fabricating mechanisms. The four keep the reasons
they had, one of them improving ("also leaks how the backend handles errors").
Both qualifiers are rendered into the lesson body, so the distinction survives
into the UI rather than living only in the analysis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): restate a standard once the reviewer has rejected it several times

Ingestion writes a lesson from the first rejection that produces it and, on every
rejection after that, only bumps its counters. So a standard that has fired eight
times is still worded for the screen it was born on, and still carries no
boundary — both of which are measurable on this owner's history.

Replayed against 898 circled rejections: of 44 rejections in the same category as
a distilled standard but from other acceptances, the standard reproduced the
reviewer's actual objection on 4, while firing on 39% of deliveries they had
accepted. A rule written from one case and bounded by nothing does exactly that.
It is worth fixing rather than routing around, because the repeats are real:
grouping those rejections by the standard behind them puts 62% of the spacing
complaints and 40% of the unrequested-decoration ones into groups of 2+, largest
12. Placement reaches only 11%, which is why refusing has to be a first-class
answer rather than a failure.

The pass reads every instance at once, so the wording can climb to what they
share instead of what one of them said, and it reads deliveries the reviewer
ACCEPTED alongside them, which is the only input that can justify a limit. A
boundary inferred from the instances is invented; one read off a delivery they
shipped is theirs. On the divider group this is the difference between "输入框内部
工具栏不应添加多余的分隔线" and a standard that exempts danger-action separators in
menus, table headers and sibling list rows, each citing the accepted delivery it
was read from — verified against those frames rather than taken on trust.

Two design points came out of running it on real rows instead of fixtures:

`generalized: false` is an answer, not an error. Instances that share a category
do not share a standard: "put the status next to the title" and "move topics
above the profile" are both placement and are not one rule. The refusal is
recorded as a revision anyway, or the same instances are re-read on every
subsequent rejection.

Taste survives consolidation. Given six spacing rejections the model answered
`taste` and left the boundary unwritten rather than manufacturing a mechanism
from six data points — which is the behaviour that keeps a preference from being
enforced as if it were objective.

Consolidation runs after the write and never inside it: a round of rejections
that is safely recorded must not roll back because the rewrite of an unrelated
standard failed. No schema change — `revision.kind = 'generalize'` and
`prevTitle` were designed for this and had no writer until now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): make every generalized boundary name the delivery it rests on

The consolidation prompt asked the model never to invent an exemption, but
nothing checked. Its `limits` was one free-text sentence, and the deliveries it
cited ("S2") were per-request labels that meant nothing once the request was
gone — so "why is this allowed" had no answer, and an invented exemption looked
exactly like a real one.

`limits` is now a list, each entry citing the shipped deliveries it was read
from. `resolveLimits` maps those labels back to check result ids and drops any
entry that resolves to nothing: no refs, a rejected instance's ref, a label that
was never listed. What survives is written into the lesson and, with its ids,
into `revision.evidence` alongside the instances and the shipped set the pass
was offered — refusals included, so "not one standard" can be checked against
the same rows.

Fixes a second way to get a boundary wrong, in the other direction: a rewrite
used to replace the lesson's limits wholesale, so a boundary the reviewer had
stated could be silently dropped, widening the standard past what they said.
A carried-over limit is now marked as such and kept only if its wording is
already in the standard; when nothing survives, the existing limits section
stays.

Replayed on the divider group from production: two exemptions, citing S2 (the
profile "⋯" menu that isolates Delete) and S4 (the documents table), resolved to
their check result ids; both frames checked by eye. Spacing wrote none, and the
mixed placement group still refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(expertise): distil the final round, and stop two rounds racing without a queue

Three fixes from review, all on paths the replays never exercised.

**The last round of every acceptance was never learned from.** Distillation was
scheduled only when a later round attached, on the reasoning that a new round is
the first server-side proof the previous one is settled. But a terminal accept or
reject settles a round just as surely, and it is the only thing that can settle
the last one — no later round ever follows it. `stampDecision` now returns the
round it stamped and both terminal paths hand it to distillation. Accepting still
settles it: a reviewer can accept overall while individual checks were rejected
along the way, and those rejections are the material. Triggering a round twice is
harmless — the reflection key makes the second pass a no-op.

**Without a queue, two rounds settling together raced.** The queue path buys
serialization with `flowControl`; the local path had nothing, so both runs could
see no bound domain and create duplicate defaults, or distil against the same
stale lesson catalog and fork a lesson that should have attached — the
persistence lock only serializes the writes, and would record both. Local runs
now chain per reviewer, on the same key the queue uses. A failed distillation
does not cancel the rounds queued behind it.

**Consolidation may no longer promote taste to mechanism.** Replaying one group
twice with an unchanged prompt returned `taste` once and `mechanism` once. The
two are not symmetric: `mechanism` is the permissive value — it is what lets a
standard compile into a criterion that blocks a delivery on its own. Ingestion
decides this at the lesson's birth from the reviewer's own words, so a later pass
promoting it means model noise can arm the gate with a mechanism nobody stated.
Admitting taste needs no such proof, so downgrades still apply.

Also corrects what `expertise_lessons.backtest` claims. Its doc described scoring
a standard's precision against past decisions as the pre-compile gate; measuring
that on 898 circled rejections showed the labels are too incomplete to gate on — a
reviewer circles the worst thing in a delivery, so a standard correctly spotting a
defect they did not circle reads as a false alarm. The column keeps its place and
its emptiness, with the numbers written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(expertise): keep the reviewer's own limits, and move the revision counter with a system pass

Three more review findings, all in the consolidation pass.

**The next user edit of a consolidated lesson would have failed.** A system pass
numbered its revision from the log's maximum but left `expertise_lessons.
currentRevision` behind, while `reviseLesson` numbers from `currentRevision + 1`
against a unique `(lesson, revision)`. Two system passes, or one after a user
edit, and the reviewer's next rewrite collides. The counter now moves inside the
same transaction as the revision — including on a refusal, which records a
revision too — and it is derived from the larger of the log and the counter, so a
lesson whose counter has already drifted repairs itself.

**A rewrite could silently drop a boundary the reviewer drew.** `limits` was the
model's complete list, so carrying an existing limit depended on it echoing that
limit back: omit one of two and the exemption disappears, widening a standard the
reviewer had bounded. Existing limits are now kept verbatim by the service and
never re-derived from the answer; the model returns only what it is ADDING. The
one exception is ingestion's "边界未由评审者说明" placeholder, which only a reader
can tell from a real boundary — so the model answers that as a flag, and the
placeholder is dropped only when a validated exemption arrives to replace it.

**Serialization was keyed too narrowly.** Domains and their bindings are
workspace-wide, so two members reviewing at once race exactly as one member's two
rounds do, and a per-user key lets each mint a different default domain and split
the catalog. Both the local chain and the queue's `flowControl` now key on the
catalog: the workspace when there is one, the user otherwise.

Replayed against production rows again, since the limits contract changed: the
divider group wrote two exemptions citing the profile "⋯" menu and the documents
table, both resolving to real check result ids; a variant carrying a pre-existing
reviewer limit kept that limit and appended to it. One of those replays came back
degenerate — the model emitted collapsed text — and the pass contained it: the
invented refs resolved to nothing, so no boundary was written and the reviewer's
limit survived untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(expertise): sample only the accepted rounds the caller may read

**A teammate's creator-only round could end up quoted in a shared standard.** The
contrast set was every accepted check in the workspace, but a workspace shares its
lesson catalog, not every round in it: a round is creator-only unless it is
public. This pass puts a delivery's title, the reviewer's comment and their
circled notes into a prompt, then persists the result as a limit the whole
workspace reads — so the sample is now restricted the way a reader would be. The
caller's own checks, plus other members' only where the round they belong to is
public; a check with no round is the caller's own by the scope predicate.

**One rejection could count as two.** A repeated label in the model's answer
("R1", "R1") wrote two hits for the same rejection and incremented `hitCount`
twice. That count ranks the standards list and decides core-versus-niche, so the
distortion is durable even though consolidation later deduplicates the ids.
Deduplicated at the write itself rather than at one caller, since that is the only
place hits are made and the topic path shares it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:47:46 +08:00
Arvin Xu 6f64c187b1 ♻️ refactor(mecha): assemble the tool manifest pool in the shared core (#19616)
* ♻️ refactor(mecha): assemble the tool manifest pool in the shared core

`assembleManifestPool(sources, options)` owns the pool rules both
tools-engine constructors used to duplicate: a connector that produced a
manifest replaces the same-named plugin, manifests executed outside the
connector path get the connector's tool permissions patched in,
context-aware builtins resolve for the run, every source drops entries the
engine could not use, and excluded identifiers leave the pool from every
source. The connector → manifest listing and the permission patch are
shared too; the server's builder only adds the endpoint and credentials
the runtime needs, the browser's only maps its store rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ♻️ refactor(mecha): build the activator discovery pool in the shared core (#19617)

The server pipeline no longer owns the discovery rules. `@lobechat/mecha`
now decides which identifiers the tools engine considers
(`resolveInvocationToolIds`), what the activator may discover mid-run
(`resolveDiscoveryPool`: the enabled manifests, discoverable builtins,
the connector families with their source, the sandbox and device
reachability deletions, and the single ingest guard every source passes
through) and which tools dispatch to the client (`resolveClientExecutors`).

`toolDiscovery` shrinks by ~180 lines and only supplies the run's facts.
Device reachability keeps coming from the execution plan's kind, so an
`auto` run with several machines online still offers the picker.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* 🔊 fix(mecha): report the manifests the exclusion actually removed

The diagnostic logged the size of the excluded set, which overstates the
count whenever it names an identifier no source contributed — exactly the
device builtins the walls already dropped. The assembler returns the real
before/after delta instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:44:22 +08:00
Arvin Xu 230b8037f1 🐛 fix(hetero): stop settling a run whose output ingest discarded as success (#19658)
* 🐛 fix(hetero): stop settling a run whose output ingest discarded as success

A device/sandbox hetero run can finish with every one of its events thrown
away and still be recorded as a clean turn: the user is left on the `...`
assistant placeholder forever, with no error, no retry affordance, and the
topic/task retired as answered.

`heteroIngest` has two paths that discard a whole batch and answer 200 — the
operation row is no longer `running`, and the topic's `runningOperation`
marker no longer names this operation. The producer reads 200 as delivery, so
`BatchIngester.drain()` succeeds, `exitedClean` is true, and `heteroFinish`
receives `result: 'success'` for a run that persisted nothing.

Observed on a real run: the CLI did all the work (16 steps, opened the PR it
was asked for, $5.31 of tokens) while the server stored one `stream_start` and
then silently dropped the rest.

Three changes, so the loss is reported wherever it can be:

- `heteroIngest` returns `{ accepted, reason }` and stamps the first refusal
  on the operation row. A refusal is permanent, so it is an ack, not a
  transport error the producer should retry.
- `BatchIngester` fails the stream on a refusal instead of retrying, which
  surfaces at `drain()` and makes the run finish as an error carrying why.
- `heteroFinish` reads the row marker back and downgrades a `success` receipt
  to a failure, covering producers that predate the ack contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(hetero): let terminal-operation callbacks reach the refusal path

`authorizeOperationCallback` rejects an operation-token callback with CONFLICT
as soon as its row is terminal, so `heteroIngest` never reached the refusal it
was just taught to report: the ingester spent its whole retry budget on a
permanent failure, no rejection marker was recorded, and `heteroFinish` was
turned away by the same guard — leaving the turn on its unresolved placeholder.

`hetero:ingest` and `hetero:finish` now opt into `allowTerminalOperation`. The
allowance is per call site, never implied by the capability, so token renewal
(which also asks for `hetero:ingest`), goal mutations and `model:invoke` stay
strict. Every other check still runs, and neither callback gains any write
power: the service refuses them on the same terminal status, which is precisely
the answer they exist to deliver. It also aligns the narrow token with the
user-session producers, which skip this guard entirely today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:30:05 +08:00
Rdmclin2 91f36af7de 🐛 fix(agent-runtime): dedupe archived tool results by content and page readKnowledge (#19665)
* 🐛 fix: dedupe archived tool results by content and page readKnowledge

Archived tool results were keyed by `{topicId}_{toolCallId}`, so the same
oversized knowledge-base read persisted a byte-identical document per topic,
and the `.tool-results` root folder could be created twice under concurrency.
`readKnowledge` also returned whole files, which pushed almost every call past
the archive threshold and bloated the model context.

- Archive under `./.tool-results/{sha256(content)}.txt`; reuse an existing
  identical archive and only add the topic binding. Fall back to
  `{hash}_{toolCallId}.txt` when the hash path holds different content.
- Run mkdir/stat/write/verify/associate inside one transaction guarded by a
  per-agent `pg_advisory_xact_lock`, so concurrent archives cannot create
  duplicate root folders or duplicate rows.
- `readKnowledge` returns a bounded window per file (default 400 lines,
  10k chars) with `offset`/`limit` for paging; the prompt carries the line
  range and a continue hint, the card shows the range, and the manifest and
  system role tell the model to page instead of re-reading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix: enforce the readKnowledge char cap on oversized single lines

A line longer than the per-call cap (minified JSON, generated text) was
returned whole, which defeated the bound and could still push the tool result
past the archive threshold. Cut such a line at the cap, keep advancing one
line so paging cannot stall, and tell the model the line was cut and where to
continue.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:24:11 +08:00
Rdmclin2 ebe586289d ✨ feat(page-editor): align document comments beside their source text (#19659)
* ✨ feat(page-editor): align document comments beside their source text

Adds a gutter that positions comment threads next to the highlighted
text they annotate instead of stacking them in a flat list below the
document, with per-line quick-comment affordance and anchored/
unanchored thread partitioning in the comment list API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🌐 i18n: sync generated locale translations

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix(page-editor): hand failed anchored drafts back and bound the gutter's eager load

- A failed anchored submission stashed while a newer draft owned the
  shared slot was written under `anchored-failed:<clientId>` and never
  read again, so the toast was the last the reader saw of their text.
  The provider now moves the stash back into the slot whenever that
  slot frees (the newer draft sent or cancelled, or the next open) and
  republishes its anchor, remounting the gutter composer beside the
  original selection with the failed words and attachments, ready to
  retry. The inline composer, always mounted and already holding the
  slot's state, adopts the stash itself on mount instead.
- Anchored threads were drained to the last page whenever the document
  mounted, panel open or not, and every result mounted twice (gutter and
  list). Fetch ahead only while the gutter shows them, up to
  ANCHORED_EAGER_PAGE_LIMIT pages; a picked highlight past that fetches
  its thread on demand through the existing deep-link pin, and the list
  below the body now pages both caches so the rest stays reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix(page-editor): forward paging keys from gutter controls and clear the marker over root whitespace

- Keyboard focus usually enters the panel on a card's button or link,
  and the key forwarder skipped every key for any interactive target, so
  PageUp/PageDown from there did nothing: the control has no use for
  them and the browser default cannot scroll the sibling document pane.
  Decide ownership per key — a field or editable region keeps everything
  typed into it, a button or link keeps only Space (its activation).
- Over the body's own whitespace between or after blocks, `blockAt` is
  null and the move handler returned early, leaving the marker for the
  last crossed line actionable for text no longer under the pointer;
  the host-level handler never sees those moves either, since the body
  contains the target. Clear it there unless the pointer is on the
  bridge to (or over) the current marker, the one path meant to survive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix(page-editor): report a missing deep-linked reply once when the gutter duplicates the thread

An anchored thread shown in both the gutter and the flat list received
the same focus in both copies, so a deep link to a missing or failed
reply ran both copies' missing-focus effect: two toasts, and the focus
token advanced twice. The flat copy keeps the focus for its highlight
but no longer answers for a miss when the gutter shows the same thread.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix(page-editor): partition thread pages client-side when the server ignores the scope

The `anchored` filter on listThreads is new in this change. A production
backend predating it strips the unknown key and answers the anchored and
document scopes with the same mixed pages — which is exactly what the
documented debug proxy (latest SPA against app.lobehub.com) produces —
so the two caches overlapped and the list below the body rendered every
such thread twice, with duplicate React keys. Filter each page to its
scope's own subset on the client; the cursor still walks the server's
order, so paging stays consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:45:08 +08:00
YuTengjing 270a4a50cb ✨ feat(fts-search): add pg_like provider for plain PostgreSQL (#19188)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:14:27 +08:00
Arvin Xu 8b7a674d22 ✨ feat(cli): add lh doctor to diagnose the whole chain in one command (#19626)
* ✨ feat(cli): add `lh doctor` to diagnose the whole chain in one command

Diagnosing a broken `lh` today means knowing which layer to suspect: `status`
answers only "does the gateway accept this machine", `whoami` only "who am I".
Everything else — a node too old for the CLI's own imports, a device gateway
that belongs to another deployment, a workspace scope saved under a different
account, a provider with no key — surfaces much later as an error that names
the symptom rather than the cause.

`lh doctor` walks the chain in order and stops reporting derived symptoms once
it finds a root cause:

  runtime → endpoints → credentials → scope → run context → server → device →
  execution

- Profiles: bare `doctor` runs the core layers (fast, cheap); `--profile
  connect | agent | hetero | selfhost | all` adds the deeper ones.
- `--offline` skips everything that touches the network, `--deep` opts into the
  costly checks, so a diagnostic never spends money by default.
- `--json` prints the same report object the terminal renderer consumes, one
  entry per check with `status`, `detail`, `evidence`, `fix` and — for skips —
  the upstream check or flag that excluded it.
- Exit code contract: 0 unless something failed, `--strict` promotes warnings.
- `--fix` applies only the repairs a check knows how to make (stale workspace
  scope, unparseable settings.json, loose permissions, leftover daemon state)
  and re-runs the check so the report shows the repaired state.
- `--deep --agent <id|slug>` starts a real run and reports tokens and cost,
  proving the chain end to end instead of inspecting it.

Also extracts the credential precedence (`LOBEHUB_JWT` > explicit flags >
`LOBEHUB_CLI_API_KEY` > stored login) into `pickAuthSource`, shared by
`resolveToken`, the tRPC client and doctor — it was spelled out twice and had
already drifted on which env names it accepts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): stop doctor from leaking proxy credentials or repairing healthy state

Five findings from an independent review of the doctor series, all real:

- `--fix` deleted the pid/status files of a daemon that was merely reconnecting,
  orphaning a live process `connect stop` could no longer find. Repairs now run
  only for the finding a check marks `evidence.repairable`, and each repair
  re-checks which state it was handed. The runner skips repairs entirely for
  findings without that marker, so a warning with nothing to undo no longer
  reports a failed repair.
- Proxy env vars were copied into the report verbatim. `http://user:pass@host`
  is a normal way to configure one, and the report is meant to be pasted into an
  issue — URL credentials are now redacted.
- A self-hosted server with the default device gateway failed `endpoints.resolution`,
  which skipped reachability, credentials, identity and every agent check behind
  it. An installation that only uses the HTTP API needs no device gateway, so
  this is a warning; the device profile's own handshake still fails concretely.
- `--fix` dropped a valid saved workspace scope whenever `LOBEHUB_WORKSPACE_ID`
  overrode it for one run, silently moving the user to personal scope once the
  override went away.
- `getAiProviderList` reports a provider's toggle, not whether a key is stored,
  so an enabled-but-keyless provider was reported as ready and the run then died
  with `InvalidProviderAPIKey` — the exact failure the check exists to pre-empt.
  Agent readiness now reads that one provider's key vault and names where the key
  comes from, saying "not verified" rather than passing when a restricted API key
  cannot see vaults at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 📝 docs(cli): record why the node check cannot catch a too-old runtime

Closing that gap means changing how the CLI is launched, which is not this
command's to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): keep doctor from crashing, over-claiming, or leaking identifiers

Ten findings from a second independent review, all confirmed:

- A socket failure reaching the gateway arrives as an EventEmitter `error`
  event, which `connect().catch()` cannot see. With no listener the whole
  report died with "Unhandled error" instead of reporting a failed handshake —
  verified by removing the new listener and reproducing the crash against a
  refused port.
- Server, gateway and proxy URLs are redacted at the report boundary, not just
  in the proxy variable: a basic-auth URL in `LOBEHUB_SERVER` used to print its
  password in `--json`. Gateway error text also quoted the WebSocket query
  string, carrying hostname, device id and user id; the account email is now
  masked too. The raw values still flow to whatever connects with them.
- "Enabled on the server" no longer claims a key exists. `deepseek` is enabled
  unconditionally in the server's own config, and `ENABLED_OPENAI` /
  `ENABLED_OLLAMA` default to true, so the report now says the server reports
  it enabled and that server-side keys are invisible from here.
- A provider holding only a `baseURL` is a local runtime (Ollama, LM Studio),
  not a missing key: it warns instead of failing, so it no longer blocks the
  deep round trip through `dependsOn`.
- The round trip only reads "no longer tracked" as success once it has seen the
  run live. The server also answers null when there is no state at all, which
  made the check incapable of failing.
- A saved workspace scope is only repairable when the account is identified and
  differs. Under `LOBEHUB_CLI_API_KEY` there is no readable subject at all, and
  `--fix` was deleting a selection that was still valid for the stored login.
- The settings repair no longer overwrites an existing `.bak`.
- The update check bounds its own registry request: the runner's timeout would
  otherwise surface as a failure and a non-zero exit, for something this check
  only ever reports as a warning.
- The command sets `process.exitCode` instead of calling `process.exit`, which
  does not wait for stdout to drain and can truncate a piped JSON report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): redact the server URL the workspace-scope check reports

Sweeping every check's evidence for an injected credential — the way the
acceptance round does — turned up one the previous pass missed: the scope check
copies the resolved server URL verbatim, so a basic-auth self-hosted URL put its
password into `--json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): make the URL redactor linear instead of backtracking

CodeQL flagged js/polynomial-redos: matching the URL lazily and then requiring
a `?` backtracks across the remainder of the string at every starting position,
so an error message with many `ws://` runs takes quadratic time. Tokenise
first and decide per token — same output, no backtracking, with a test that
fails if the quadratic shape comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): scrub the doctor report centrally and stop it lingering after exit

Seven findings from a third independent review, all confirmed:

- Redaction had leaked in a third place: Node's fetch error quotes the full
  request URL, and capability and daemon evidence copied gateway URLs as-is.
  Per-check redaction had now missed an instance in each of three rounds, so
  the runner scrubs every result — detail, fix, evidence, repair messages —
  before it enters the report: URL userinfo, WebSocket query strings and email
  addresses, by linear token scanning rather than a backtracking pattern.
- With `process.exit` gone, the process waited for the event loop to empty, so
  a request abandoned by a check timeout, or the registry check's uncleared
  timer, kept it alive — up to the full `--timeout` after the report printed.
  The command now exits from a stdout write callback: after the output has
  drained, and no later.
- The registry check clears its timer and gets a runner budget that always
  outlasts its own deadline; a sub-second `--timeout` no longer turns its
  warning into a failure.
- A settings.json that cannot be read is no longer classified as corrupt and
  offered to `--fix`, which moved a possibly valid file aside unparsed. The
  repair also re-confirms the parse failure before moving anything.
- A run that stops being tracked is inconclusive even after it was seen
  running: the server returns null for lost state too, so only `isCompleted`
  proves the round trip.
- A live daemon that has not reported a connection state is a warning, not
  "connected as device unknown".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:00:18 +08:00
YuTengjing 4366b09a92 🐛 fix: isolate agent share visitor files (#19649) 2026-09-17 10:31:44 +08:00
YuTengjing b3f55110f0 ✨ feat: capture provider-native stream diagnostics (#19643) 2026-09-17 01:52:41 +08:00
YuTengjing 0ec5e3e404 ✨ feat(agent-share): let share visitors attach files to a turn (#19569)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:48:24 +08:00
Innei 101c110474 🐛 fix(desktop): stop crashing the main process when a user folder cannot be resolved (#19598)
* 🐛 fix(desktop): stop crashing the main process when a user folder cannot be resolved

On Windows, app.getPath('pictures') (and music/videos/downloads) throws
when the Known Folder is redirected or missing. The gateway
system_info_request handler called it without a guard, so sending a
message took down the whole app. Resolve those folders through a
tolerant helper, catch handler failures, and drop unresolved paths
before they reach the prompt template so it falls back to
'(not reported)'.

* 🐛 fix(desktop): always answer failed system-info requests and bound the gateway read

A rejected getShellInfo() or an unguarded app.getPath call used to be
logged without a correlated reply, and getDeviceSystemInfo() posted
without a timeout, so the agent run stayed parked for the lifetime of
the socket. Reply with success: false on failure and apply the shared
device-query timeout so the run degrades to unavailable system info.

* ✅ test(desktop): cover a throwing optional folder lookup in the gateway system-info handler
2026-09-16 22:11:12 +08:00
Northword 1e5fd6270a ✨ feat(heterogeneous-agents): run pi over the RPC transport with cross-turn process reuse (#18346)
Co-authored-by: AmAzing129 <115673583+AmAzing129@users.noreply.github.com>
2026-09-16 17:28:49 +08:00
Rdmclin2 9c377eecf0 ✨ feat(page-editor): anchor document comments to a text selection (#19602)
* ✨ feat(page-editor): anchor document comments to a text selection

Let a reader create a comment from a selected run of the document body.
The comment stores a text-quote anchor (quote + prefix/suffix + offsets)
that re-locates itself against the current body on every load, and the
editor paints an inline highlight and thread badge for each anchored
root comment. Replies inherit the thread's anchor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(page-editor): paint every anchored thread and keep selection comments on locked pages

Body highlights now come from a document-wide listAnchors endpoint instead
of the paginated thread cards, so an anchored comment beyond the loaded
pages is still discoverable from the body; picking such a run pins its
thread the way a deep link does.

The Comment selection action moves to a selection-only floating toolbar
that stays registered while the editor is read-only, so a reader can still
comment on a selection while another collaborator holds the edit lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(page-editor): pin a body-picked thread without scrolling to the list

A focus target now carries whether landing scrolls the card into view: a
notification deep link does, a pick made in the document body does not,
so pointing at a sentence never throws the reader down to the comments.

Also drops the "badge" wording from the anchor contract — the body paints
a highlight for each anchored thread, nothing more.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ⚡️ perf(page-editor): resolve comment anchors once per distinct quote and body text

Anchor resolution moves into a pure resolveAnchors step that reuses every
result while the flattened body text is unchanged and scans once per
distinct anchor, so a formatting-only update no longer re-runs a full
indexOf for each orphaned quote.

Also stops marking the composer's quote row aria-disabled: only a jump
target whose run is gone is disabled, not a row holding an operable
Remove control.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:51:07 +08:00
Arvin Xu 585fa593a5 🔖 chore(cli): bump version to 0.0.55 (#19618)
Ships the hetero `/goal` conversation flow (#19588), the operation-token
renewal for long hetero exec runs (#19549), the wrapped artifact share URL
(#19536) and readable task slugs (#19503).

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:23:11 +08:00
Arvin Xu 1f311b14b2 ♻️ refactor(mecha): decide the tool rules of a run in the shared core (#19604)
* ♻️ refactor(mecha): decide the tool rules of a run in the shared core

Move the tool-mode derivation, the per-tool enable rules, the default
tool selection and the device walls out of the two tools-engine
constructors into `@lobechat/mecha` as `resolveToolRules(request)`. Both
hosts now assemble facts (pinned plugins, execution target, device
facts, model capabilities, knowledge / memory / search flags) and feed the
shared result to `createEnableChecker`.

Alignment agreed in review:
- the mode is chat / custom / agent from the chat config alone on both
  hosts; the browser no longer demotes a run to chat mode when the model
  cannot call functions (the engine marks the tools incompatible instead)
- the cloud sandbox tool is offered for the `auto` target on the browser
  too, and memory follows the host-resolved flag
- the device picker and the device walls only apply behind a device
  gateway; the browser runtime supplies no device facts and keeps its
  platform filter

The pure mode / plan predicates move to `@lobechat/types`; the device
tool registry and the builtin-tool wall move to mecha, re-exported from
their server homes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(mecha): never offer the device picker without a gateway

Gateway facts and device policy are separate inputs now: the picker and
the Computer Use opt-in need a gateway device, while the policy / plan
walls apply with or without one. A self-hosted server without a gateway
whose persisted target is `device` no longer advertises a picker it
cannot dispatch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 07:13:13 +08:00
Arvin Xu 394ba3a54f ✨ feat(goal): create and supervise a goal from a hetero agent conversation with /goal (#19588)
* ✨ feat(goal): let /goal create and supervise a goal from a hetero agent conversation

- /goal in a Claude Code / Codex / Kimi conversation injects instructions to
  run `lh goal create --conversation` and plan in the same run; no goal tool.
- The server creates the goal from the running operation: the conversation
  agent supervises it, the goal is bound to the topic, and that run is adopted
  as the first planning turn (turn token returned to the CLI).
- Device and gateway runs authenticate with an operation token carrying the new
  goal:manage capability; local desktop runs use the login with their topic.
- lh hetero exec echoes LOBEHUB_OPERATION_ID / LOBEHUB_TOPIC_ID into the agent.
- Management spend counts only planning turns, not the rest of the chat.
- Conversation shows linked goals above the composer; a new Goal portal view
  shows the whole goal, and goal cards open it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): keep Claude Code from taking over /goal and plan from the create output

- Strip the /goal prefix from the prompt sent to hetero CLIs. Claude Code has
  its own /goal command; sent verbatim it pinned a session goal and a stop hook
  that would not let the run end, while the LobeHub goal waited for that run to
  end before dispatching work. The instructions stay in the system context.
- Tell the agent to plan from the graph printed by lh goal create instead of
  running lh goal show, which the run's operation token cannot call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): show a goal the running conversation creates without a reload

The linked-goal tray read the topic's goals once on mount and only polled
once a goal existed, so a /goal run that created one mid-run left the tray
empty until the page reloaded. Poll while the conversation is generating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): scope goal:manage to /goal runs and keep adopted-turn spend

Codex review on #19588:
- Every hetero run got goal:manage, so an ordinary run could create goals
  through the CLI; grant it only when the prompt is a /goal request (renewal
  copies the minted claims).
- A later manager turn replaced the receipt's adopted/operationId, dropping
  the conversation run's cost from goal usage and the budget check; carry
  adoptedOperationId across turns and count it even after a topic handoff.
- The linked-goal tray polled the goal list on every generation; poll for new
  goals only while a /goal request is generating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:36:58 +08:00
Arvin Xu 8fa80700de 🐛 fix(desktop): confirm hetero cancellation when the app lost its task record (#19603)
After a desktop restart both in-memory registries are empty, so
cancelHeteroTask answered "No task found" without exited: true and the
server kept the task unstoppable forever. Look the operation up in the OS
process table instead: confirm the exit when no hetero exec wrapper is
alive, terminate an orphaned wrapper group and confirm only after it exits,
and stay unconfirmed when the lookup fails. Windows keeps today's answer.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:05:30 +08:00
Arvin Xu c5ec20abab ✨ feat(docker): run self-hosted Compose chats in Gateway Mode by default (#19583)
* ✨ feat(docker): run self-hosted Compose chats in Gateway Mode by default

- Add the lobehub-gateway service to docker-compose and wire the lobe service
  to it; AGENT_GATEWAY_INTERNAL_URL keeps server pushes on the Compose network
  while browsers use AGENT_GATEWAY_URL.
- S3_INTERNAL_ENDPOINT routes the server's own S3 requests to rustfs:9000 while
  presigned URLs keep the browser-facing S3_ENDPOINT, which local mode sets to
  localhost and the container cannot reach.
- setup.sh generates a per-deployment JWKS_KEY and gateway token and writes the
  gateway URL for each deploy mode; the .env template drops the public JWKS key.
- Warn loudly at container startup when an upgraded stack lacks the gateway
  settings or still uses the public example JWKS_KEY, and document the upgrade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🔒 fix(docker): pass only the public JWK to the gateway container

The gateway only needs the public half of JWKS_KEY to verify browser
sessions, but it received the full private key set, so anyone able to read
the gateway environment could sign tokens LobeHub trusts. setup.sh now also
writes JWKS_PUBLIC_KEY (the same key without d/p/q/dp/dq/qi), Compose passes
only that to the gateway, and the startup check warns when it is missing,
still contains private fields, or does not match JWKS_KEY.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:33:26 +08:00
Arvin Xu c2e0428bee 💄 feat(goal): show who each task is assigned to on the goal page (#19591)
* 💄 feat(goal): show who each task is assigned to on the goal page

The current-task list and the exploration graph named a task but never who
was doing it, so a goal routed to a separate executor read the same as one
the supervising agent runs itself. The graph read now carries each dispatched
task node's assignee from its Task row, and both surfaces render the agent's
avatar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(goal): open the assignee's profile card and pin it top-right on graph cards

Review feedback on the assignee avatar: a bare name tooltip says little, so
hovering the avatar now opens the agent profile card groups already use. On
exploration graph cards the avatar moves to the top-right corner and the
verifier shield takes the bottom-left of the metric strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(agent-profile): label the agent runtime and hide the model for external agents

Review feedback on the goal assignee card: it never said whether the agent
is Claude Code or a built-in agent, and showed a LobeHub model for a Claude
Code agent, which is not what runs it. The profile card now tags the name
with the runtime (or "Built-in agent"), and an external agent shows the
runtime it runs on instead of a model or a model switcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(agent-profile): drop the runtime tag beside the agent name

Review feedback: the card needs neither a "Built-in agent" tag nor the
runtime tag next to the name. The footer line naming an external agent's
runtime in place of the model stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): narrow the assignee read and refresh goal graphs after reassignment

Codex review on #19591:
- The graph poll read full task rows (instruction, editor data, config) for
  every task node just to take the assignee; read only id + assigneeAgentId.
- Reassigning a task refreshed only task caches, so a paused or finished goal,
  which does not poll, kept showing the old executor; revalidate goal graphs
  when the assignee changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:21:34 +08:00
Hardy e966c4f948 🐛 fix(device): 修复设备文件操作误报不在批准的工作区内的多个场景 (#16962)
* 🐛 fix(device): register workspaces, allow symlinks, include worktree paths

- include worktree (git.activeWorktree) in approved workspace roots
- register device skill roots in an in-memory cache so previews pass the guard
  without polluting the UI's working-directory list
- allow symlinked directories in desktop and CLI file preview check
- detect Windows UNC paths correctly in the path containment check
- normalize filesystem path traversal (../) in the CLI fallback check

* ♻️ refactor(device): use pathe instead of manual win32/posix branch

* 🐛 fix(device): restore missing pathe relative import
2026-09-15 23:18:10 +08:00
Tsuki f8cba5f95e ⏪️ revert(chat): remove topic execution isolation (#19596)
* ⏪️ revert(chat): remove topic execution isolation

* 🐛 fix(agent): honor workspace device overrides on resume
2026-09-15 08:13:39 -07:00
Arvin Xu 119c09ab39 ♻️ refactor(mecha): decide the model parameters of an LLM call in the shared core (#19586)
* ♻️ refactor(mecha): decide the model parameters of an LLM call in the shared core

Move the model-parameter rules out of the server's per-attempt hints into
`@lobechat/mecha` as `resolveModelParams(request, providers)`: which extend
params the model consumes (user row → provider card → canonical card),
where the reasoning effort comes from (topic pin, only for the pinned
model and, in a group topic, the answering agent → model-instance config →
sub-agent override), when assistant reasoning must be replayed (Kimi,
DeepSeek thinking, Meta, card support, provider fallback), the capability
defaults the context may assume, the history window, and the stream flag.

The server adapter now only supplies lookups (`createServerModelParamsProviders`)
and the run's frozen snapshots. `stream` falls back to the agent's
`chatConfig.enableStreaming` when the operation did not pin one; the
DeepSeek model-id predicates move to a dependency-free module so the shared
package can import them without the model bank.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(server): trace the streaming mode the request really uses

The chat span recorded `ctx.stream ?? true` while the payload now honours
the agent's chat config, so a newly non-streaming request was labelled as
streaming in telemetry. The built context rides along on the LLM trace
input and both the payload and the span read the same resolved value.
Also point the add-model-provider integration map at the shared
resolver as the policy owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 21:39:34 +08:00
Arvin Xu 43e71aa778 ✨ feat(agent-runtime): hand a server run's turn back when messages are queued behind it (#19585)
* ✨ feat(agent-runtime): hand a server run's turn back when messages are queued behind it

A queued follow-up only interrupted a run at the next step boundary in the
browser runtime; a Gateway run never learned about the composer's queue and
made the follow-up wait for every remaining step.

The client now mirrors whether messages are queued behind its running Gateway
run into a per-operation flag (owner-checked, with a share-visitor mirror). The
server reads it before each step and hands it to the agent's step context, so
the existing queued-message interrupt ends the turn early.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(chat): skip the queued-messages request when a Gateway run starts with an empty queue

Every Gateway run sent a pending:false flag request on start, even though a
run starts unflagged and nothing was queued behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(agent-runtime): keep a queued follow-up's steer mark on the Gateway path

In Gateway mode the server persists the user message and its snapshot replaces
the client's optimistic row, but only the request trigger was forwarded. A
follow-up drained from the input queue lost metadata.steer and rendered as a
new turn instead of a continuation of the interrupted one.

Forward steer through execAgentTask (and the share-visitor mirror) and persist
it on the user message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(agent-runtime): order queued-flag writes and treat each step's read as authoritative

Addresses review feedback on the queued-messages flag:

- Queue-then-delete fired two independent requests; a delayed pending:true
  could land after pending:false and end the run with an empty queue. Writes
  for one operation are now sent in order, keeping only the latest wanted value
  while a request is in flight.
- The step boundary only ever added hasQueuedMessages, so a context arriving
  with true would keep ending the turn after the queue emptied. The read now
  sets or removes the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:29:03 +08:00
Arvin Xu 1f4d6a7116 ♻️ refactor(chat): gather the browser's per-step context through the shared rules (#19577)
* ♻️ refactor(chat): gather the browser's per-step context through the shared rules

The browser's `contextEngineering` no longer decides which facts a turn
needs. It assembles a `ContextFactRequest` from the stores and hands the
lookups to `createBrowserContextFactProviders`; `gatherContextFacts` in
`@lobechat/mecha` applies the same rules as the server runtime. The
chat service stops prefetching agent documents and building the agent
builder context itself — both now come out of the shared rules.

Browser behaviour aligned with the server:
- inside a workspace the creds tool lists the workspace's organization
  creds (new `market.creds.listForContext`, scoped on the verified
  workspace header); personal creds otherwise
- Composio services and connector status count only ACTIVE connections
- the group builder's shared prompt reads the group content, and its
  official tools list comes from the shared catalog helper
- custom (MCP) plugins are listed in the agent-management context
- agent documents follow the edited agent while the builder is active,
  resolved cache-first through the store
- `contextEngineering` accepts a `shareVisitor`, so a visitor run gets the
  same gates as on the server

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(mecha): keep uninstalled builtins out of the builder's installed tools

`listOfficialTools` marked every registered builtin as installed, so the
browser lost the tool store's uninstalled filter and the builder could pin
identifiers the user had removed. The uninstalled list is now a fact the
host provides (`listUninstalledBuiltinIds`): the browser reads the tool
store, the server reads the user's tool settings for the run's scope, and
both builder contexts list those tools as `installed="false"`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(mecha): seed uninstalled builtins on the server and keep visitor topic ownership in the browser

- The server provider returned nothing for a never-configured scope, so a
  new account or a workspace without its own slot saw every builtin as
  installed. It now falls back to `defaultUninstalledBuiltinTools`, the
  same seed the browser's tool store applies.
- The browser's `findTopic` dropped the ownership fields, so a share
  visitor's `<refer_topic>` could never pass the shared visibility rule. A
  visitor's store only holds topics the server already scoped to that
  visitor and agent, so the provider attributes cached topics to the
  visitor and routes their message reads through the share endpoint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 16:31:19 +08:00
Arvin Xu 237392c468 ♻️ refactor(mecha): decide which context facts a step needs in the shared core (#19576)
* ♻️ refactor(mecha): decide which context facts a step needs in the shared core

Move the "what to gather" rules for the per-step context (topic references,
agent documents, onboarding, workspace links, plan/todo, credentials,
Composio services, agent/group builder, agent management, official tools,
sandbox and identity variables) into `@lobechat/mecha` as
`gatherContextFacts(request, providers)`. Hosts now only answer typed
lookups (`ContextFactProviders`); the server adopts it and its twelve
providers collapse into one fetcher factory.

Rule alignment with the browser, as agreed in review:
- plan block is injected on the server when a plan document exists;
  history still wins for the TODO state
- credentials are only fetched with `lobe-creds` and use the workspace's
  organization creds inside a workspace
- Composio services are gated on the creds tool and the deployment feature,
  disabled ids come from the run's tri-state `disabledPluginIds`
- onboarding follows the server rule, including the already-injected check
- topic references skip when the context is already injected
- agent-builder documents target the edited agent on both hosts
- the agent-management context (available agents / providers / plugins) is
  now actually injected on the server; the previous computation was dead
- official tools include LobeHub skill connectors on the server
- share-visitor gates live in the shared rules, keyed on the principal

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🔒 fix(mecha): keep the owner's agents, providers and plugins away from a share visitor

Auto skill mode still enumerated the creator's other agents into
<available_agents> for a visitor run. The agent-management facts are
owner-scoped, so they are skipped whenever the request carries a share
visitor; @-mentions still flow through.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(server): offer installed custom MCP connectors in the agent-management context

The run's tool source map only classifies skill, Composio and client
tools, so filtering it for 'mcp' never yielded a custom connector and
createAgent / updateAgent could not offer one. Read the user's installed
custom plugins from the plugins table instead — pinned or not — which is
what the browser lists from its tool store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:51:42 +08:00
Arvin Xu 73a2f2ec65 🐛 fix(goal): make the goal agent the supervisor and fix exploration graph layout (#19578)
* 🐛 fix(goal): make the goal agent the supervisor and fix exploration graph layout

- goal.agentId is now the supervising agent: it owns the agent's goal list,
  the goal page and every main-Agent planning turn. The separate
  config.manager.agentId is removed.
- New config.taskAgentId routes coordinator-created tasks to a dedicated
  executor (falls back to goal.agentId). Add setTaskAgent / `lh goal
  set-task-agent` and `lh goal create --task-agent`; setAgent no longer moves
  tasks held by a dedicated executor.
- A supervision handoff opens the next management topic in the new agent's
  history; turns from the previous agent can no longer submit plans.
- Task result portal and supervision panel get a "..." menu to copy the task
  link / task ID / topic ID.
- Exploration graph places each node under its parents and relayouts on the
  cards' measured heights, so findings no longer overlap tall task cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 fix(goal): move the task result menu next to the task name and open the supervision topic

- The task result portal's "..." menu now sits right after the task name, like
  the goal and supervision headers, instead of beside the close button.
- The supervision panel's "..." menu can open the manager topic in the
  supervising agent's own chat, next to Copy Topic ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): harden executor routing and supervisor handoff against races

- Fall back to the goal agent when the dedicated executor was deleted;
  taskAgentId is JSON, so deleting the agent does not null it and createTask
  rejected the stale id on every tick.
- Write config.taskAgentId with an atomic JSONB patch (setTaskAgent and
  restart) instead of replacing the whole config, so a concurrent budget or
  acceptance edit is not discarded.
- Refuse a planning claim when the locked goal row names a different goal
  agent than the graph snapshot, so a handoff landing mid-claim does not
  dispatch the previous supervisor or spend the new one's turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): keep goal supervision topics out of the chat sidebar

The main-Agent planning topic and the recovery supervisor topic were created
without a trigger, so they showed up in the agent's chat sidebar and Recent
like ordinary conversations. Tag both with a dedicated goal_supervision
trigger and exclude it wherever system topics are filtered (sidebar fetch,
topic selectors, Recent). The goal page still opens them by id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:13:42 +08:00
Arvin Xu c3a7642295 ✨ feat(oauth-apps): support web applications with redirect URIs (#19504)
* ✨ feat(oauth-apps): support web applications with redirect URIs

OAuth apps created from the settings page were device-flow only: no
redirect URIs, no response types, and no client secret. That covers CLIs
but leaves no way to register a server-side app that signs users in with
a LobeHub account through the authorization code flow.

Adds a `web` application type alongside the existing device type. A web
app gets `authorization_code` + `refresh_token` grants, a `code` response
type, the redirect URIs its owner registers, and a confidential client
secret issued once at creation with a rotate action afterwards.

The secret is encrypted at rest with `KeyVaultsGateKeeper`, mirroring how
API keys are stored, and decrypted in the OIDC adapter because the token
endpoint compares it in plaintext. A secret that fails to decrypt is
dropped rather than passed through, so the request fails as
`invalid_client` instead of making ciphertext the credential.

Redirect URIs are matched exactly: no wildcards, no fragments, no
embedded credentials, and https everywhere except loopback hosts. The
same rules run in the form and again in the tRPC input, since only the
server-side pass is binding.

No migration: `oidc_clients` already carries every column this needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(oauth-apps): keep the adapter's secret round-trip symmetric

Two follow-ups from review.

`Client.upsert` still wrote the secret in plaintext while `find` now
expects ciphertext, so anything persisting a client through the adapter
would read back a credential it could not authenticate with. Encrypt on
the way in too, and leave the stored secret untouched when an update
payload omits it. The conflict branch also referenced a camelCase key
that never existed on the payload, so it silently wrote nothing.

An `update` carrying an empty redirect URI list passed validation and
stripped a web app of every callback, contradicting the create-time
requirement. Reject a list that normalizes to empty wherever one is
supplied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(oauth-apps): address acceptance review on create, detail and consent flows

Application type is now picked from two icon cards that explain each
kind, instead of a bare segmented toggle. Redirect URIs move out of the
create dialog and are configured on the app page afterwards; the type
stays at creation because it decides whether a client secret is issued
and cannot change once the app exists. A freshly created web app shows
that it has no redirect URIs yet and cannot sign anyone in until one is
added.

The detail page separates reading from editing. Basic info and redirect
URIs each render read-only with an Edit action, and open a form with
Save and Cancel only on request. Redirect URIs are edited as a list of
rows rather than a newline-separated textarea.

Authorization errors the provider cannot redirect back with, such as an
unregistered redirect URI, now land on a dedicated page with a plain
explanation per error code instead of a raw JSON dump. Only `error` and
`error_description` are forwarded.

The consent and sign-in confirmation cards center their title and
subtitle so the text lines up with the centered application logo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ♿ fix(oauth-apps): label redirect URI row removal and color the list error

The icon-only remove button on each redirect URI row had no accessible
name, so assistive tech announced it as an unlabeled button.

The "at least one redirect URI" message rendered in body color: antd's
ErrorList only picks up error styling inside a Form.Item with an error
status, and the list-level message sits outside one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:25:20 +08:00
Arvin Xu d1a33225f7 ⚙️ chore(goal): raise the default per-Task attempt budget from 3 to 8 (#19575)
Three attempts ran out on long-horizon Tasks whose work was already done: two
were spent on infrastructure (an expired operation token, an abandoned run) and
the third on a review asking for one more piece of evidence, so a finished Task
stopped the whole goal on a person.

The default now applies consistently everywhere a goal gets its budget:
- `resolveTaskAttemptBudget` server default (goals without an explicit budget)
- `DEFAULT_GOAL_MAX_ROUNDS`, which the create-goal modal prefills and submits
- the createGoal tool manifest and the /goal system prompt, which told the model
  to default `maxIterations` to 3 and therefore pinned tool-created goals

An explicit `recovery.maxAttemptsPerTask` is unchanged, and the 2–10 range stays.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:22:30 +08:00
Arvin Xu fbd47003da ♻️ refactor(server): split the per-step context gathering into fact providers (#19574)
* ♻️ refactor(server): split the per-step context gathering into fact providers

`serverCallLlmContextBuilder` grew to 851 lines of sequential lookups against six database models, the market service and Composio, interleaved with the engine parameter assembly. Each lookup is one fact for the context snapshot and none depends on another, so they are now independent providers under `modules/Mecha/ContextEngineering/providers/` — topic references, agent documents, onboarding context, LobeHub skill placeholders, user info, workspace context, sandbox placeholders, plan TODO, creds list, Composio services, agent builder and group agent builder contexts — behind one `gatherServerContextFacts` that runs them concurrently and returns them grouped the way the snapshot consumes them (`agentDocuments`, `step`, `variables`).

The builder is left with orchestration: hints, gather, assemble, run the engine under its span. Every provider keeps its share-visitor gate and best-effort error handling verbatim; the two dynamic imports become static. The agent / group builders share `listOfficialTools`.

Concurrency is the one behavior change: a step now pays for the slowest lookup instead of the sum, which is the remaining server-side cost on the send path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ⚡️ perf(server): cap the context fact fan-out at eight concurrent lookups

Twelve providers share one Postgres pool and two of them reach external services; an unbounded `Promise.all` let a single step grab every connection at once. `runBounded` drives the thunks through `p-map` with a concurrency of 8 while keeping each result position's type, and the gatherer test now asserts the cap directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:00:16 +08:00
Arvin Xu 08704326a3 🐛 fix(expertise): skip history topics when the provider account refuses (#19539)
* 🐛 fix(expertise): skip history topics when the provider account refuses

A user's own provider account failing (out of credits, revoked key) threw out
of the expertise-history topic step, so Upstash logged it as a 500 and retried
every topic of the backfill against an account that cannot recover. Finish
those runs as a skip; transient failures still throw and retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ♻️ refactor(expertise): handle provider account refusals in the ingestion service

The skip belongs next to the other ingestion outcomes, not in the Upstash
workflow, so both backfill runtimes share it. The local fallback loop now also
stops at the first refusal instead of hitting the same dead account per topic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:31:01 +08:00
Arvin Xu 0fff23f8b5 ⚡️ perf(agent): fetch LobeHub skill tools concurrently with a per-request timeout (#19533)
* ⚡️ perf(agent): fetch LobeHub skill tools concurrently with a per-request timeout

Tool discovery runs on the execAgent send path, before the operation exists,
so the user watches the "sending" spinner for all of it. LobeHub skill
manifests were fetched one connection after another, and only listConnections
had a timeout: a user with several connected skills paid the sum of every live
MCP probe, and one hanging provider held the whole send.

- getLobehubSkillManifests fetches every connection's tools with Promise.all
  and keeps manifests in connection order.
- The live probe and the static fallback each get their own
  LOBEHUB_SKILL_DISCOVERY_TIMEOUT_MS budget, so a hung live probe still
  leaves room for the static list. Callers that pass no timeout (the tools
  router) behave as before.
- Discovery's IO stages (model metadata, LobeHub skills, Composio, online
  devices, device system info) now emit spans, the only breakdown of the
  wait between "message saved" and "operation started".
- loadModels is a static import like its other server callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ♻️ refactor(market): bound skill tool discovery fan-out with p-map

Promise.all let the number of connected skills decide how many tool-list
requests hit Market at once. p-map keeps connection order and caps the
fan-out at LOBEHUB_SKILL_DISCOVERY_CONCURRENCY (5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(agent): mark degraded tool discovery stages in their spans

Codex review: two discovery stages absorb their failures, so their spans
stayed healthy and the new telemetry could not tell a Market timeout from a
user with no connected skills.

- traceDiscoveryStage moves to pipeline/discoveryTracing and hands the span
  to the stage; markDegradedStage records a failure count and sets ERROR
  when the stage swallowed failures.
- model_metadata counts rejected Promise.allSettled lookups.
- getLobehubSkillManifests takes an optional onError, called for each failed
  provider (with its providerId) and for a failed connection lookup; the
  lobehub_skills span records the manifest and failure counts from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:18:41 +08:00
Arvin Xu a452cb6b71 🐛 fix(verify): send evidence frames inline and stop review errors from spending Task attempts (#19565)
* 🐛 fix(verify): send evidence frames inline and stop review errors from spending Task attempts

The Acceptance review handed screenshots to the provider as links the provider
had to download. When it could not reach the storage behind them (a bucket on
localhost or a private network), every review of that check failed with
"Error while downloading file", and the Goal Task paused as
"Verification could not run". The coordinator recovers that string by starting
another builder attempt, so the same delivery was redone until the attempt
budget ran out without ever being judged.

- Read frames from our own storage and send them as data URIs (link fallback
  above 5 MB or when the bytes cannot be read). Shared by the review predictor
  and the inline LLM judge.
- Retry a Goal review that could not run once in place, which absorbs a
  dropped connection.
- If it still cannot run, pause with a new ACCEPTANCE_REVIEW_ERRORED_ERROR
  contract that has no recovery branch, so it goes to a person or the main
  Agent instead of another paid attempt the builder cannot fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(verify): retry only the check whose review could not run

The settle path retried an errored Goal review by rerunning the whole review.
Every other check was re-asked and its opinion upserted over the first one, so
in a review where one check rejected and another errored, a nondeterministic
second pass could turn the rejection into an acceptance and let the delivery
complete. Retry the errored check alone, inside the review, and keep every
other verdict from the first pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:53:41 +08:00
YuTengjing 687d50f5c3 🐛 fix: show safe traceable errors in shared topics (#19560) 2026-09-14 18:10:37 +08:00
Rdmclin2 8e32440965 🐛 fix(workspace): default documents to edit and pin self in assignee picker (#19555)
🐛 fix(workspace): default workspace documents to edit and surface self in the assignee picker

Workspace documents graded every member down to `view` by default, so a page
published into the workspace read as read-only for everyone but its creator
even though the server's write path never enforced that level. Privacy for a
written page is already carried by `visibility: 'private'`, so publishing one
is the creator choosing to collaborate.

Also lifts the current user out of the workspace member group in the task
assignee picker, so assigning a task to yourself no longer means scanning the
directory.


Claude-Session: https://claude.ai/code/session_019XxbYCrsrChYN2MbLSoMfc

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 17:29:48 +08:00
Arvin Xu 7222bf648e ✨ feat(gateway): send op meta on init and issue user-level gateway tokens (#19556)
* ✨ feat(gateway): send op meta on init and issue user-level gateway tokens

Groundwork for the per-user multiplexed gateway socket: the init call now
carries the op's topic/agent/parent linkage so the hub can build a user-level
feed, and clients can mint a user-scoped JWT without a running topic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🔒 fix(api-key): block restricted keys from minting the gateway user JWT

`aiAgent.issueGatewayUserToken` inherits the `agent:read` namespace scope,
but the cli-sandbox JWT it returns passes `oidcAuth` as ordinary non-API-key
auth — the same reason `refreshGatewayToken` is on the blocked list. Add it
there and cover both in the scope-guard tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:23:34 +08:00
Arvin Xu 1488baa3fb 🐛 fix(agent): settle deferred step work between inline steps (#19541)
* 🐛 fix(agent): settle deferred step work between inline steps

Work that a step defers with `after()` normally waits for the HTTP response.
Budget settlement is part of that work. Each LLM call reserves a budget hold in
`beforeChat`, and releases it in the cost tracker's `onFinal`, inside
`after()`. While every step was its own request, the hold was released as soon
as that step responded.

The inline step loop sends a single response once the whole loop finishes. So
the hold from every step in an invocation stayed reserved until then, and
available budget is `maxBudget - spend - reserved`. This shrinks the room each
subsequent reservation sees. It also amplifies an existing reservation race
that reports "Budget exceeded" while plenty of credit is left. After the loop
rolled out, one heavy account got five such rejections within an hour with
tens of millions of credits available.

`runWithScheduledWorkScope` now wraps the loop. Inside it, `after()` starts
work immediately and tracks it, and `flushScheduledWork` waits for that work
at each step boundary, before the next step reserves again. The boundary wait
is capped at 10s so one stuck telemetry call cannot stall an operation. Work
still running when the loop ends goes back to the host, as before. Outside
the scope, `after()` behaves exactly as it did.

The fix relies on the async context reaching the budget callback. It does:
agent LLM steps call the model runtime in-process, and the stream whose
`flush()` fires `onFinal` is created inside the step. Node binds the context
where a stream is created, and a regression test pins that behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(agent): hand the step to the queue when deferred work does not settle

Codex review. The step-boundary flush gave up after 10s and the loop started
the next step anyway. The captured work does not tell telemetry apart from
the settlement that releases the previous step's budget hold, so a slow
settlement let the next step reserve on top of the unreleased hold, bringing
back the false "over budget" rejection this wait exists to prevent.

`flushScheduledWork` now reports whether it settled. On timeout the loop
stops inlining and hands the pending step to the queue, which is how it ran
before steps were inlined: the unfinished work keeps running and the queue
round-trip gives it time to land before the next reservation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:17:47 +08:00
Arvin Xu c0c0ac0b27 🐛 fix(cli): renew the operation token during long hetero exec runs (#19549)
* 🐛 fix(cli): renew the operation token during long hetero exec runs

`lh hetero exec` received its operation token once, as LOBEHUB_JWT, and the tRPC
client froze it into the link when the client was built. Past the token's four
hours every ingest was rejected and the run's output never reached the server.

- Renew the token an hour before it expires through
  aiAgent.refreshHeteroOperationToken, retry transient failures every minute,
  and stop on UNAUTHORIZED / FORBIDDEN / CONFLICT. Desktop session tokens are
  left to their own refresh flow.
- Read LOBEHUB_JWT per request in the CLI tRPC clients, so the renewed token
  reaches the ingest sink that holds its client for the whole run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): stop renewing against a server without the renewal endpoint

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): keep renewing the operation token until the finish receipt is sent

Renewal was stopped before the final drain. The drain and the heteroFinish
receipt both authenticate with the operation token and can sit in retries, so a
token expiring in that window rejected the remaining events and the completion.
Stop renewal only after the last server call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:16:13 +08:00
Arvin Xu 4565c140d6 🐛 fix(hetero): let a long-running hetero run renew its operation token (#19548)
The server signs the hetero-operation token for four hours and offered no way
to get another. A Goal Task running longer than that got 401 "exp claim
timestamp check failed" on every heteroIngest — heartbeats included — so the
lease stopped renewing and the operation was reclaimed as abandoned while the
agent kept working.

aiAgent.refreshHeteroOperationToken re-mints a token with the same claims. It
accepts only an operation token, and runs the same re-authorization every
operation callback already does (operation still running, principal still
active and permitted), so renewal never outlives revocation.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:22:03 +08:00
Innei 4c94aee97b ✨ feat(acceptance): pin the list rail on wide screens and add shift/cmd multi-select (#19559)
* 🐛 fix(desktop): ignore stale dist bundles when orchestrating dev startup

Claude-Session: https://claude.ai/code/session_01E7e9mKLWsnGPgvgB42oM3V

* ✨ feat(acceptance): pin the list rail on wide screens and add shift/cmd multi-select

Claude-Session: https://claude.ai/code/session_01E7e9mKLWsnGPgvgB42oM3V
2026-09-14 15:08:18 +08:00
Rdmclin2 021bd4b4b3 🐛 fix(editor): rehost pasted document images (#19527)
* 🐛 fix(editor): rehost pasted document images

* 🐛 fix(server): cap remote image fetch size

* 🐛 fix(editor): handle rehosted image URLs

* ✅ test: type happy-dom URL helper
2026-09-14 12:57:30 +08:00
YuTengjing 41ce61b35d ⚡️ perf(search): skip ineligible message indexing with versioned capture (#19537) 2026-09-14 12:14:38 +08:00
YuTengjing 4367a885eb 🐛 fix: clarify artifact delivery and preserve tool refusals (#19409) 2026-09-14 10:25:04 +08:00
Innei 1918aaf433 ✨ feat(acceptance): opt-in purge of evidence files when deleting (#19529)
* ✨ feat(acceptance): purge evidence files and rounds when an acceptance or run is deleted

Deleting an acceptance (single, batch, or from the task detail) or a verify
run now removes its rounds, reports and the evidence files only they
referenced, freeing storage quota. A shared delete confirm previews the
rounds, files and bytes the purge will free.

Claude-Session: https://claude.ai/code/session_01LK4wVB74664gvoXthzwf6N

* ✨ feat(acceptance): make full purge an explicit opt-in when deleting

Deleting an acceptance detaches its rounds again by default, so shared
round report links keep working. The delete confirm gains a checkbox that
opts into purging every round, report and evidence file, previewing what
the purge frees before the destructive path is taken.

Claude-Session: https://claude.ai/code/session_01LK4wVB74664gvoXthzwf6N

* 💄 style(acceptance): show the purge warning in place of the description when opted in

Claude-Session: https://claude.ai/code/session_01LK4wVB74664gvoXthzwf6N

* 🐛 fix(acceptance): scope purge to exclusive evidence files and honor storage policy

- purge only files with source=acceptance that no run outside the purge set references
- pass REMOVE_GLOBAL_FILE to deleteMany and skip storage deletes when it is off
- include hashless evidence urls in the storage delete list
- purgePreview takes ids[] and computes one joint preview excluding hashes still referenced elsewhere
- task-detail delete confirm carries the consequence copy as a secondary line
- row / batch onDelete let failures propagate so the confirm dialog stays open
- removeBatch purges with concurrency 4 via mapWithConcurrency
- storage delete is best-effort: a failure is logged and the purge still completes

Claude-Session: https://claude.ai/code/session_01LK4wVB74664gvoXthzwf6N

* 🐛 fix(s3): chunk DeleteObjects requests to 1000 keys

Claude-Session: https://claude.ai/code/session_01LK4wVB74664gvoXthzwf6N
2026-09-14 01:05:01 +08:00
Arvin Xu 4a964c3547 🐛 fix(desktop): show renderer OTA version as app.rN instead of app_rN in the update notice (#19540) 2026-09-14 00:28:48 +08:00
Innei fc346bc4cb 🐛 fix(cli): print wrapped artifact share URL after publish (#19536)
* 🐛 fix(cli): print wrapped artifact share URL after publish

CLI artifact publish was emitting the raw hosting publicUrl. Match the
App by returning the product share link /share/artifact/<deploymentId>.

* ♻️ refactor: drop src/utils/artifactShare re-export

Callers import artifactShareUrl from @lobechat/utils/artifactShare.
2026-09-14 00:07:39 +08:00
Arvin Xu c33818b0d0 ✨ feat(desktop): open Quick Composer as a composer, capture screenshots on demand (#19523)
Quick Composer used to drop straight into full-screen capture mode. It now
opens as a plain composer: no scrim, no crosshair, prompt focused, and a
text-only prompt can be sent. A camera button in the panel enters the
existing capture flow; Esc backs out of an empty capture pass.

The Screen Recording permission check and window enumeration move from
startSession to a new screenCapture.beginCapture IPC, so the composer works
without the permission. When access is missing the overlay closes before the
prompt, since it sits above the dialog at screen-saver level.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:49:29 +08:00
Rdmclin2 515f3b8461 🐛 fix(task-slug): keep combining marks and fall back to the instruction (#19519)
* 🐛 fix(task-slug): keep combining marks and fall back to the instruction

`taskTitleSlug` only allowed `\p{L}` and `\p{N}`, so scripts built on combining
marks were shredded one character at a time — `कार्य पूरा करें` became
`क-र-य-प-र-कर`, `สวัสดี` became `สว-สด`. CJK needs no marks, which is why every
existing case read as correct. Add `\p{M}`, and truncate by grapheme cluster so a
cut can neither halve a surrogate pair nor strand a mark without its base letter.

The Recent feed links a task by `COALESCE(name, instruction)` while
`useCanonicalTaskSlug` read `name` alone, so a task with only an instruction had
its slug stripped the moment the detail landed, and the copied link came out
bare. Add a shared `taskSlugTitle` helper mirroring that COALESCE (`??`, so a
name cleared to `''` still collapses the URL) and read it through store
selectors everywhere a task link is built or repaired.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🔒 fix(task-slug): keep prompt bodies out of task URLs and bound the slug

A task's instruction is a prompt body, not a title, so it must not reach a path
segment — from there it lands in browser history, the analytics page view fired
on every history change, access logs, and anything pasted from the clipboard.
That was already true on canary for the Recent feed, which links a task by the
display title (`COALESCE(name, instruction)`); the canonicaliser happened to
strip it on arrival. Rather than making that fallback stick everywhere, drop it
from URLs entirely: `slugTitle` carries `tasks.name` alone through the Recent
payload, and the canonicaliser, the copied link and the Portal header all read
`name`. A nameless task simply has no slug, and every surface now agrees on
that instead of rewriting each other.

Grapheme counting is also not a size bound. A single cluster accepts an
unlimited run of combining marks, so `a` plus 10k accents stayed one grapheme
and produced a ~20KB segment, past what browsers and proxies accept in a
request line — and titles arrive from imported and generated content, not only
from typing. Keep whole clusters, but stop at a code-point ceiling as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🌐 chore: update i18n

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🐛 fix(recents): move a task's slug source with an inline rename

Splitting the link's slug source out of the display title left the two able to
drift: renaming a task from Home updates `title` optimistically and on commit,
but `slugTitle` kept the server's old name, so the row went on linking to the
previous title's slug until the next sync. That is the same
link-vs-canonicaliser disagreement this feature set out to remove, reintroduced
one field over.

A rename sets `tasks.name`, which is exactly what `slugTitle` carries, so both
the committed reducer path and the optimistic selector overlay now move it with
the title. Topics and documents have no slug source and keep theirs null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:55:01 +08:00
Arvin Xu caefd70f7f ♻️ refactor(server): resolve the run agent config through the shared mecha rules (#19525)
* ♻️ refactor(server): resolve the run agent config through the shared mecha rules

`resolveRunAgentConfig` re-implemented the agent config resolution rules that `@lobechat/mecha` already owns for the client. It now gathers the snapshot from the server's sources (agent row, workspace member overrides, author-or-admin access, reply language), runs `resolveAgentConfig`, and layers only the per-call intents a run knows about: the member device override, callSubAgent chatConfig patches, the IM `/mode` override, persistence-attribution ids and the `instructions` append.

Two rule gaps closed on the shared side so both hosts agree:

- `AgentConfigResolverContext.modelOverride` carries a per-call model / provider pin (task and sub-agent spawns, explicit request overrides) into `resolveAgentModelConfig`; the runtime template renders the resolved model by default.
- Builtin system-role precedence is decided by the builtin, not the host: `RuntimeContext.storedSystemRole` hands the row's own prompt to the runtime, and the inbox (a user-editable default assistant) honors it, while feature builtins keep their runtime prompt.

Server-visible changes: regular agents now receive the reply-language instruction the client already appended; builtin runtime chatConfig merges apply; sub-agent runs keep lobe-agent and rely on its manifest resolver to hide `callSubAgent`, as the client does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(server): apply the member device override before the builtin runtime policy

Codex review: the device preference was layered after `resolveAgentConfig` had merged the builtin runtime's `agencyConfig`, so a saved `local` / `device` override replaced an onboarding runtime's `executionTarget: 'none'` and routed an internal agent to a device it disables. The override now lands on the row before the shared rules run, restoring the previous ordering where the runtime policy is merged last. Regression test covers both the builtin and the regular-agent case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(server): read the reply language for the share visitor on shared-agent runs

Codex review: `AiAgentService` is constructed as the share owner, so the locale appended to a regular agent's system role was the owner's, instructing visitor conversations to reply in the creator's language. `resolveRunAgentConfig` now takes `shareVisitorUserId` and reads the reply language for that user on share-gated runs, matching how the timezone and user info are already resolved for the visitor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 22:51:07 +08:00