Commit Graph

13854 Commits

Author SHA1 Message Date
Arvin Xu a9313e4041 🐛 fix: prevent send continuation from yanking user back to the sent topic
A send's continuation calls switchTopic(createdTopicId) after the
persistence round-trip resolves. When the user navigated to another
topic while that await was in flight, the unconditional switch yanked
the UI (and the URL) back to the sent topic.

Add an onlyIfActiveTopicIn guard to switchTopic: send flows pass every
conversation bucket their conversation can currently live under -- the
client-minted topic id before the server confirms it, the persisted id
after the optimistic re-key. The switch is dropped when the user has
already moved elsewhere. Accepting a set rather than a single id is what
keeps the guard from dropping the send's own normal adoption of the
topic it just created.

The store's activeTopicId uses null for "no topic" but is typed string
and can hold undefined from callers that never went through switchTopic,
so normalize before comparing: an unset field still matches an explicit
null (the blank new-conversation view).

The guard runs before the epoch bump, so a skipped switch never
invalidates a concurrent switch's pending revalidation. Applied at all
four send-flow call sites: the minted-topic adoption (guarded against
the view the send started from), and the client / hetero / gateway
persistence continuations.
2026-09-20 01:59:30 +08:00
Arvin Xu cc2f8dccf9 💄 style: expand device settings detail pane (#19680)
* 💄 style(devices): expand device detail pane

* 💄 style(devices): open device detail as a page-level right rail

Round 1 feedback rejected both checks: the device list was left-hugging
instead of centered, and the detail read as a small card beside the list
rather than a real side panel — with no coverage of a device carrying many
recent directories.

The list lost its centering because the previous pass widened the settings
content column to `none` for this tab so the detail card could sit beside
it. Restore the shared 1024px reading column and make Devices a full-width
tab that owns its own layout instead: NavHeader + centered SettingContainer
+ a page-level RightPanel. Opening the detail now only narrows the space the
list centers in.

DeviceDetailPanel splits into a fixed header and a scrolling body so a device
with a long working-directory history scrolls inside the rail instead of
stretching the page. In the workspace list card the host height stays auto,
so the panel keeps growing with its content exactly as before.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 01:38:04 +08:00
Arvin Xu 185d3c4858 🐛 fix(goal): lay manager plans out by round and surface agent-written deliverables (#19587)
* 🐛 fix(goal): lay manager plans out round by round

The main Agent plan had no dependency field and every planned task was wired
only to the problem node, so a multi-round Goal rendered as one flat row.
Plans now accept dependsOn (existing task node IDs or earlier indexes in the
same plan), written as depends_on edges after validating every reference; the
terminal acceptance Task depends on the delivered leaf Tasks so it renders last.

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

* 🐛 fix(task): title a run handoff with its result, not a status

The handoff title becomes the Goal finding title, and titles like 'Completed X'
told the reader nothing about what was found.

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

* 🐛 fix(document): credit documents written by lh doc to the agent run

Agents that write reports through lh doc create/edit never passed a tool
runtime, so no document Work was registered and a Goal's deliverables stayed
empty. The CLI now sends LOBEHUB_OPERATION_ID and the document router credits
the write to the run's root operation when the operation is owned and running.

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

* 🐛 fix(goal): create the Goal acceptance node and its links atomically

The acceptance node and its decomposes/depends_on edges were written in
separate transactions. An interruption between them left the node detached
for good, because no later tick comes back to link an existing node. They now
commit in one transaction, so a failure rolls the node back and the next tick
retries.

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

* ✨ feat(goal): show the final acceptance as a module awaiting sign-off

The terminal Goal acceptance row only carried a small status chip, so the
owner could not tell a delivery awaiting sign-off from one that failed, and
the final report was out of reach. The row now states the acceptance itself
(awaiting your acceptance / did not pass / in progress / accepted), previews
the latest acceptance report under it (also under a gate opened on that
acceptance), and opens the full report in the side panel.

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

* 🐛 fix(goal): show the final acceptance report only once the goal ran through

The report view appeared on a final acceptance that was still lost, retrying
or parked on a gate, next to a ledger of failed attempts. It now renders only
when the acceptance task is resolved and its latest round passed (awaiting
sign-off) or was accepted; every other state keeps the ordinary row. Drops the
gate-attached report card and the copy for states that no longer render.

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

* 🐛 fix(goal): preview the acceptance task delivery when the round wrote no report

A final acceptance judged by the verifier agent settles without a verify report,
so a finished Goal showed only a placeholder. The preview now falls back to the
document the acceptance task registered, then to its newest finding, and opens
that in the side panel.

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

* 💄 feat(goal): replace the task list with the final acceptance document

Once the goal's final acceptance finished and passed, nothing is left to
advance, so the whole current-task list gives way to the acceptance document:
its title, a content preview and the sign-off action, with the full text in the
side panel. Until then the list is unchanged. The preview now carries the full
report text, the document body, or the delivery finding.

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

* 🐛 fix(goal): preview the acceptance report, not the task delivery

The final acceptance card replacing the task list previewed the acceptance
Task's own delivery when no round wrote a report. That is the product being
accepted, not the judgment the owner signs off on. Read the report the round
wrote, else each check's verdict and reasoning from the latest round, and open
that round's verify report in the Portal.

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

* 🐛 fix(goal): show the goal's final document in place of the task list

The finished Goal replaced its task list with the acceptance report, while the
owner opens a finished Goal to read what it produced. Show the newest document
the work registered as a deliverable instead (preferring the work over the
acceptance task), preview its content, and open the document itself in the
Portal. Goals that left no document keep the list. Drops the report helpers
and their now-unused copy.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 01:15:53 +08:00
Arvin Xu f799ae4893 ⚡️ perf(tools): project the whole command-output family, not just one member (#19695)
`lobe-local-system/runCommand` got a projector because its output is stored
three times. It is not the only tool with that shape — `register.ts` points
`codex/command_execution`, `claude-code/Bash`, `opencode/bash` and `pi/bash` at
the SAME shared `RunCommand` card, which reads `stdout || output || content` for
the body and `success` / `exitCode` for the collapsed row. One shape, so the
existing projector already fits; it was simply never registered for them.

This matters because the hetero cohort was getting nothing from the read-path
projection. Measured on production over 24h, inside topics that contain an
external agent's tool calls, the payload is essentially all `codex` (48 MB) and
`claude-code` (4.5 MB) rows and zero `lobe-*` rows — none of which had a
projector. `codex/command_execution` alone is 44 MB/day, and its state is
`stdout` 13 MB plus a byte-identical `output` 13 MB, with the message body
carrying a third copy.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:54:47 +08:00
Arvin Xu c866610586 ⚡️ perf: make step completion state snapshots opt-in (#19682)
* ⚡️ perf: remove unused final state from step completion events

* ⚡️ perf: make step state snapshots opt-in per agent run

* 🐛 fix: use a valid FinishReason in the finish executor test

`'done'` is the resulting state status, not a FinishReason, so tsgo failed
type-check on the new opt-in snapshot test. Use `'completed'` instead.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:46:57 +08:00
Arvin Xu 49b635d80c 🐛 fix(hetero): restore projected tool bodies before replaying a transcript (#19694)
A resume replay is the one place the message store feeds a model instead of a
render. When a CLI session has been garbage-collected, the store's rows are
rebuilt into a transcript that the external agent then resumes from — so a tool
body the read path projected away is written to disk as "this tool returned
nothing", and every later turn reads it back that way.

Restore those bodies first. The fetch is bounded and only runs on the rebuild
path, which is already doing far more expensive work; a failed fetch replays the
trimmed body rather than losing the user's prompt.

Narrow in practice — a hetero topic's tool rows are almost entirely `codex` /
`claude-code` calls, which have no projector — but `lobe-local-system/runCommand`
does appear inside hetero topics, and this failure is silent and persistent.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:35:27 +08:00
Arvin Xu 5fe21123b8 🐛 fix: restore composer only when cancelling an unaccepted send (#19674)
* 🐛 fix: restore composer only for unaccepted sends

* 🐛 fix: preserve live draft edits during cancelled topic rollback
2026-09-20 00:08:23 +08:00
Arvin Xu afc7b01320 🐛 fix: recognize JSON device gateway dispatch errors (#19692) 2026-09-19 23:48:01 +08:00
Arvin Xu 9b99536f01 ⚡️ perf(agent): read a turn's invariant facts once in the send window (#19690)
* ⚡️ perf(agent): read a turn's invariant facts once in the send window

Between the user pressing send and the operation existing, two questions
were asked twice: a routed device answered the same system-info RPC over
its WebSocket (tool discovery, then operation prep), and the user's
settings row was read once for the market access token behind LobeHub
skills and again for the memory and timezone settings. `createRunFacts`
remembers each answer for the turn, keyed so a device's workspace and
personal scopes stay apart and a share visitor's row stays their own. It
remembers "there is none" too, so an unreachable device is not asked again.

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

* 🐛 fix(agent): keep a failed settings read a failure in the turn's facts

Swallowing the rejection let `execAgent` continue and read the absent row as
"nothing is set", which its `memorySettings?.enabled !== false` default turns
into memory enabled — the opposite of the conservative `false` a failed read
used to leave behind. The reader propagates the rejection now, remembering it
so the turn does not retry; the market token read, which is genuinely
optional, catches it at its own call site.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 23:25:06 +08:00
Arvin Xu dde87d1534 🐛 fix: allow stopping and deleting terminal task runs (#19688) 2026-09-19 22:37:19 +08:00
Arvin Xu 0cc24ebcf0 ✨ feat(device): record CPU architecture on the devices table (#19510) 2026-09-19 22:36:36 +08:00
Arvin Xu 066d22fd0c ✨ feat(hetero): hand off rate-limited chats to another agent (#19080) 2026-09-19 21:26:39 +08:00
Arvin Xu a193769686 ⚡️ perf: share one message read across step entry consumers (#19683) 2026-09-19 21:23:32 +08:00
Arvin Xu 92367e93a6 🐛 fix: add optimistic feedback for V2 user question submissions (#19681) 2026-09-19 21:12:28 +08:00
Arvin Xu 8f5a65efdb ♻️ refactor(hetero): unify local and remote Codex account quota (#19678) 2026-09-19 20:37:07 +08:00
Innei 83ad641c64 🔥 chore(ci): retire issue automation workflows now handled by github-bot (#19684)
* 🔥 chore(ci): retire issue-auto-comments workflow, now handled by github-bot

* 🔥 chore(ci): retire issue automation workflows now handled by github-bot

* 👷 ci: skip test/e2e runs for docs and automation-only changes
2026-09-19 20:28:32 +08:00
Innei f2dcabe604 ✨ feat(desktop): native pull request tab in the Working Sidebar (#19506)
* ✨ feat(git): pull request detail + actions via gh

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

* ✨ feat(device): pull request detail + action RPC legs

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

* 🐛 fix(device): reattach useGitRemoteBranches JSDoc after PR detail hook insert

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

* ✨ feat(pr-tab): merge dock resolver

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

* 🐛 fix(pr-tab): unreachable bypass rules label, drop fabricated review count, redundant showPush check

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

* ✨ feat(pr-tab): native pull request tab in Working Sidebar

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

* 🐛 fix(pr-tab): review round 1 — SplitButton, scoped dock errors, hook tests

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

* 🐛 fix(pr-tab): final review — fork-safe delete branch, action timeout, input guards

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

* ✅ test(device): add isCrossRepository to PR detail fixture

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

* 🐛 fix(pr-tab): cap the expanded checks list inside the merge dock

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

* 🐛 fix(pr-tab): render HTML in PR description and comments

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

* 🐛 fix(pr-tab): contain wide markdown and use ScrollArea for the pane and checks list

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

* 🐛 fix(pr-tab): lock ScrollArea viewports horizontally and cap the dock checks viewport

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

* 🐛 fix(pr-tab): address P1 review findings

* ♻️ refactor(pr-tab): GitHub-style merge dock, incremental merge context, base branch picker

* ⚡️ perf(pr-tab): render core data first and separate local status

* ✨ feat(pr-tab): expand failed CI checks in merge dock
2026-09-19 20:26:02 +08:00
Arvin Xu 33244b9a92 🐛 fix: normalize agent message errors and structured IM replies (#19676)
* 🐛 fix: normalize agent message errors and structured IM replies

* 💄 style: make chat error details opt-in

* 💄 style: unify error details background and compact narrow layouts

* ✅ test: assert normalized message errors in persistence integration
2026-09-19 20:20:13 +08:00
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 9d78862fee 🔀 chore(release): restore main-to-canary merge ancestry (#19673)
The previous main-to-canary sync copied the v2.2.17 changes into canary
as a single-parent commit (`189bd0abe5`). Consequently, `origin/main` is
not an ancestor of canary, and the next weekly release encounters
conflicts against already synchronized changes.

This PR records main (`787e3d20aa`) as a merge parent while preserving
the exact canary file tree (`7334cca4b9`). It restores ancestry without
reverting subsequent canary changes.

**Merge using “Create a merge commit”. Do not squash or rebase this PR:
either would discard the ancestry this PR restores.** The weekly release
will be based on canary after this PR is merged.

#### Audit

- Inspected the main-only release history and the prior sync commit.
- Compared all 75 paths changed between shared baseline `3bb887ca4c` and
main: 74 main file blobs are already reachable in canary history.
- The remaining `package.json` differs at the prior sync point only by
subsequent editor, UI, and pnpm upgrades; its release version is already
2.2.17.
- `CHANGELOG.md` and `changelog/v2.json` match main exactly.

#### Test

- [x] Tested locally
- [ ] Added/updated tests
- [x] No tests needed

Git checks confirm that the merge tree is identical to its canary
parent, main is its second parent, and main/v2.2.17/canary are all
ancestors. A simulated merge back into main is conflict-free and
preserves the candidate tree. No application tests were rerun because no
file content changes.

- Acceptance: not required; this is a Git ancestry repair with no
product behavior or file-content changes.

#### 🔗 Related Issue

Follow-up to the main-to-canary sync recorded in commit `189bd0abe5`
(its subject references PR 19435, which currently returns 404 from the
GitHub API).
2026-09-19 16:30:14 +08:00
Arvin Xu d5bd0a5f77 🔀 chore(release): reconcile weekly candidate with main history
Main v2.2.17 was already synchronized into canary by 189bd0abe5 without preserving merge ancestry. Audited all 75 paths changed from shared baseline 3bb887ca4c to main: 74 main file blobs are reachable in canary history; package.json differs at the sync point only by subsequent editor/UI/pnpm upgrades. Release metadata matches main. Preserve the current canary tree and record main as the second parent.
2026-09-19 16:25:48 +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
Tsuki 3af256caae 🐛 fix(tts): remove obsolete TTS UI (#19651)
* 🐛 fix(settings): remove obsolete OpenAI TTS setting

* ♻️ refactor(tts): retire message TTS UI
2026-09-18 08:53:54 -07: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
Innei 1b4df00363 🐛 fix(tasks): restore compact filled task groups (#19654) 2026-09-18 23:07:31 +08:00
Rdmclin2 48773ff4c4 💄 style(page-editor): keep the line comment marker inside the editor column (#19664)
Drop the thread count from the comments panel header, and move the hover
marker into the column's end padding so it no longer spills past the scroll
pane where the open panel covered it and forced horizontal scrolling.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 21:24:14 +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 697f66a800 ✨ feat: expose router affinity hooks (#19648) 2026-09-18 17:40:03 +08:00
Arvin Xu b0ef4b2ef1 ⬆️ chore: upgrade actions/checkout to v7 (#19656)
⬆️ chore: upgrade actions/checkout to v7 across all workflows

- Bump actions/checkout from v3/v4/v6 to v7 (latest release v7.0.1) in 36 files (61 references)
- Includes workflows, docs example, keeping version references consistent
- Note: v7 blocks checking out fork PRs for pull_request_target / workflow_run triggers by default (safer defaults); verified existing usages only checkout the base ref so no behavior change expected

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
2026-09-18 13:56:18 +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 2a3f0bcff6 🗃️ chore(database): record where a distilled standard came from, and what it rests on (#19627)
* 🗃️ chore(database): record where a distilled standard came from, and what it rests on

Four columns for turning a reviewer's rejections into delivery standards. Nothing
reads them yet; the capture pipeline and the backtest are stacked on top.

**`expertise_hits.source_check_result_id`** — the reverse lookup "which rejections
taught this lesson". A hit could only reach an acceptance through `evidence_id`,
and 260 of this owner's 876 rejections circled no evidence at all, so that path
loses a third of the provenance. `on delete set null`: the link is provenance, not
ownership, so deleting an acceptance must not delete the rule it taught — pinned
by a test that deletes the check result and asserts the hit survives.

**`expertise_lessons.reason_kind`** — whether the standard rests on a mechanism or
on the owner's taste. Both are legitimate; only one can be enforced automatically,
because a taste standard has no test a delivery could be measured against. This
has to be a column rather than a note in the lesson body: the compile step reads
it to refuse compiling taste into a criterion that blocks work on its own, and the
body's wording follows the reviewer's own language, so matching on a phrase would
silently miss for anyone not writing in Chinese.

**`expertise_lessons.reason_source`** — whether the reviewer gave the reason or the
distillation supplied it. An inferred reason is worth keeping, but it must never
carry the weight of something the reviewer actually said.

Both are decided when a lesson is born. A later rejection that supplies a real
mechanism should arguably upgrade a `taste` lesson; the upgrade rule is not worked
out, so that does not happen yet.

**`expertise_lessons.backtest`** — how a standard scored against the reviewer's own
past decisions before anything compiles it. The metric is precision on the units it
fired on, deliberately not recall: every standard covers a narrow slice, so recall
is near zero for all of them and separates nothing, while firing on approved
deliveries is the failure that makes someone switch the feature off. Read against
the base rate, not 1.0 — 876 of 1,956 judged units are rejections, so random firing
already scores ~0.45. jsonb because the metric set is still moving.

**`EXPERTISE_SUBJECT_TYPES` gains `standalone`** so a practice run can mirror the
subject of the acceptance it came from (36 of 193 carry no in-product subject).
`text({ enum })` emits no constraint, so it costs no migration.

No indexes: the read paths are lesson → hits (already indexed) and the lesson row
itself, and `expertise_hits` is the one table here with real scale risk.

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

* 📝 docs(database): write the expertise schema comments in English

Comment-only change; the schema itself is untouched (verified by comparing the
comment-stripped AST before and after).

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

* 🗃️ chore(database): keep which delivery each generalized boundary rests on

A consolidation pass rewrites a standard from several rejections and may write
exemptions into its `limits`. The only thing that makes such an exemption
legitimate is a delivery the reviewer accepted while the standard's subject was
plainly in view — and the rewrite text cannot carry that: the model cites
deliveries by per-request labels ("S2") that mean nothing once the request is
gone. Without a stored link, "why is this allowed" has no answer a week later.

`expertise_lesson_revisions.evidence` (jsonb, nullable) records the rejected
deliveries the pass read, the accepted ones it was offered, and for each
boundary the accepted check result ids it was read from. null on a person's
rewrite, whose authority is `feedback`. jsonb rather than a link table: a pass
reads a handful of deliveries and is audited one revision at a time; the ids are
provenance, so a deleted check simply stops resolving.

Folded into the branch's unshipped 0165 migration by regenerating it, per the
migration guide, so the PR still carries a single migration.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:02:45 +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 1adb625d4c 🐛 fix: refresh work galleries after document deletion (#19647) 2026-09-17 10:08:47 +08:00
YuTengjing b3f55110f0 ✨ feat: capture provider-native stream diagnostics (#19643) 2026-09-17 01:52:41 +08:00
YuTengjing 170161bb93 🐛 fix(task): keep artifact previews in run drawer (#19641) 2026-09-17 00:08:14 +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 1edd085789 🌐 i18n: add copy for invalid shared page format (#19628) v2.2.18-canary.18 2026-09-16 22:19:36 +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
Tsuki 5c12148b8e 🐛 fix(auth): allow Android passkey origins (#19640) 2026-09-16 06:28:51 -07: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>
v2.2.18-canary.17
2026-09-16 17:28:49 +08:00
Rylan Cai 52756f6904 ✨ chore(eval): add run Harbor Skill (#19625)
* ✨ feat(eval): add local Harbor harness

* ✨ feat(eval): support local and cloud Harbor targets

* 🐛 fix(eval): expose npm lh binary to Harbor shells

* 🐛 fix(eval): harden Harbor bootstrap and runner
2026-09-16 14:18:50 +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 846740a38f 💄 style(goal): drop the duplicated 8px block padding around goal sections (#19619)
Each section of the goal process-control band wrapped its content in an
extra 8px block padding. The accordion header already carries 6px of its
own, so every title ended up floating in ~14px of space above its content
and ~14px below the previous one, which read as an oversized hover bar on
a three-character label.

Remove the wrapper: the header keeps its 6px (it is the click target), and
the sections now sit 6px under their titles.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:25:17 +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 7bca055b6a ✨ feat(resource): preview files and pages in an inline right detail panel (#19599)
* ✨ feat(resource): inline right detail panel for file clicks

Single click on a file row now opens an in-context split-screen detail
dock (metadata + live preview) instead of jumping into the fullscreen
editor. Double click keeps the committed fullscreen gesture and the
?file= deep-link write. Folders and pages keep their existing behaviors.

* 🐛 fix(resource): render the fetched file in the detail panel

The panel read a nonexistent `fromQuery` field off the SWR response, so a
file that was not already in the loaded list left the panel blank. Move the
lookup into `useDetailPanelFile`, which reads SWR's `data`, and cover it
with a regression test. Also import `DraggablePanel` from base-ui and stop
passing `flexShrink` through to the DOM.

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

* 💄 feat(resource): collapse the detail panel properties by default

Give the preview the whole dock and move the file properties behind an info
toggle in the panel header, next to download and close, matching the
fullscreen editor's header actions.

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

* ✨ feat(resource): preview pages in the detail panel and fit grid columns to its width

Single click on a page row now opens the right detail panel with a read-only
Markdown preview; double click keeps the fullscreen page editor. The library
sidebar tree keeps opening the fullscreen editor, since it also renders where no
explorer panel exists.

The masonry grid derives its column count from its own content width (200px
minimum card, 2 to 5 columns), so opening the panel drops it to two columns
instead of squeezing four.

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

* 🐛 fix(resource): address review on the detail panel lookup and double click

Resolve the panel item from the resource store as well as the legacy
fileList. Explorer rows only live in resourceMap, so every first open of a
visible row rendered a blank panel and fired a duplicate getKnowledgeItem
request.

Wait out the double-click window before expanding a closed panel. Expanding
the panel narrows the explorer and reflows the masonry, which could move the
clicked card before the second click and swallow the double click. Switching
an already open panel stays instant, and a double click cancels the pending
open.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
v2.2.18-canary.16
2026-09-16 01:37:39 +08:00