Commit Graph

1493 Commits

Author SHA1 Message Date
Neko Ayaka 055901b1be 🐛 fix(quick-note): preserve pinned document history revisions 2026-09-19 23:39:06 +09:00
Neko Ayaka 527d4f31d9 ✨ feat(quick-note): gate access with user rollout flags 2026-09-19 23:15:10 +09:00
Neko Ayaka 51f87c5236 🐛 fix(quick-note): harden persistence and CI 2026-09-13 17:17:18 +08:00
Neko Ayaka 41e46f056a ✨ feat(quick-note): add configurable agentic analysis 2026-09-13 16:46:15 +08:00
Neko Ayaka 9e325d3ed2 ✨ feat(quick-note): add agentic processing pipeline 2026-09-13 15:31:32 +08:00
Innei 791974d831 ✨ feat(desktop): collect renderer garbage while idle and from the DevDock memory panel (#19497)
Oilpan keeps freed blink_gc pages committed after large DOM churn, so the
renderer stays at ~3G private while only a few hundred MB is live. Run
HeapProfiler.collectGarbage from the main process when no window has focus
for 30s and a renderer exceeds 1.5G resident (5 min cooldown), and expose the
same call as a GC button in the DevDock memory breakdown.

Claude-Session: https://claude.ai/code/session_01Y6cR5U6xxfWWwJHzeeV5Sa
2026-09-13 14:14:39 +08:00
Innei 2f96875229 🐛 fix(desktop): apply renderer OTA reliably (#19509) 2026-09-13 14:01:52 +08:00
Arvin Xu 93fc45ff77 ♻️ refactor(agent-runtime): carve principal, plan and host slots out of state.metadata (#19513)
Third and last step of the state restructure (#19498 world/binding, #19505 origin). The remaining creation-time facts in `state.metadata` fall into three business groups, each now a typed slot on `AgentState`:

- `principal` — under whose authority the run acts and what it may do: `actor.{shareVisitor, bot, deviceScope}`, `policy.deviceAccess`, `audit.{clientIp, userAgent}`. Decided at creation and re-presented, never re-derived, at each dispatch boundary.
- `plan` — how the run executes: `execution` (the resolved `ExecutionPlan`), `skills`, `stream`, `eval`, `workingDirectory`. The duplicated `metadata.modelRuntimeConfig` is dropped in favour of the existing top-level `modelRuntimeConfig`.
- `host` — what the host needs to deliver and retry the run: `hooks` (serialized webhook configs) and `queue.{retries, retryDelay}`. Carried by the runtime without interpretation.

`ExecutionPlan` / `ExecutionPlanUnroutedReason` move to `@lobechat/types` so the runtime can type the slot; `src/helpers/executionTarget` re-exports them. `normalizeAgentState` lifts the legacy keys on load and folds a legacy `metadata.modelRuntimeConfig` into the top-level slot when it is absent. `HookDispatcher.dispatch` accepts the wire-shape `SerializedAgentHook[]` the slot carries and narrows to `SerializedHook` itself; `isAgentShareRun` takes the state; `resolveRunActiveDeviceId` / `resolveRunProjectSkills` read `plan` and `principal`; the dynamic intervention resolvers receive the plan's working directory alongside the ledger.

`metadata` is now the run ledger only: what the runtime and host write while the operation executes.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:18:32 +08:00
Rdmclin2 35098de372 ✨ feat: add readable task URL slugs and workspace seat copy (#19503)
* ✨ feat: add readable task URL slugs and workspace seat copy

Task detail URLs gain a cosmetic `/task/:taskId/:slug` segment. The slug is
built from the task title and never participates in resolution — `taskId`
stays the only key — so stale or hand-edited slugs still resolve, and
`useCanonicalTaskSlug` rewrites the URL to the current title.

Also bundles the workspace seat locale strings for pending-invite seat
accounting, and the knowledge base library visibility confirmation copy.

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

* 🐛 fix(document): scope the library mirror update to the caller's own file

`setVisibility` mirrors a page's visibility onto its paired `files` row, but
scoped the update by file id and workspace alone. `fileId` is a plain foreign
key and a document can point at a file somebody else uploaded — parsing another
member's shared file yields a caller-owned document that keeps the original
`fileId` — so taking such a derived page private also hid the uploader's
original from the whole workspace.

Require `files.userId` to match the caller, the same guard the `documents`
update in this transaction already applies.

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

* 🐛 fix(task): collapse the task URL when the title is cleared

`useCanonicalTaskSlug` gated on the title itself, so a resolved task whose name
is empty looked identical to one whose detail had not loaded yet. Clearing a
task title persists `name: ''` (the input saves the raw value), which left the
URL pinned to the slug of the title that had just been deleted.

Read loaded-ness separately from the title so an empty name is honoured and the
URL collapses back to `/task/:taskId`.

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

* 🐛 fix(task): stop the My tasks board from hiding automated tasks

The "My tasks" board copied `automated: false` from the boards around it, but
its list view sends no automation filter at all. A scheduled or heartbeat task
assigned to the caller therefore disappeared the moment they switched that same
collection from list to board — the one place the two views must agree.

Extracts the scope-to-query mapping into `buildKanbanGroupQuery` so the rule is
stated once and covered by unit tests instead of living in a nested ternary.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 12:32:03 +08:00
Arvin Xu 518059aed1 ♻️ refactor(agent-runtime): carve the typed origin slot out of state.metadata (#19505)
Second slot of the state restructure started in #19498. Every identity, trigger and lineage fact about a run — which conversation node it hangs on, who asked for it, whether it is a child and of whom — lived as loose keys in the untyped `state.metadata` bag, spread in from `appContext` at creation and read back by string key across the runtime package, the server executors, completion, abandonment and intervention paths.

`AgentState.origin` (`AgentRunOrigin`) now carries them, frozen at operation creation and mirroring the durable operation row:

- conversation node: agentId / topicId / threadId / groupId / sessionId / documentId / scope / taskId / userId / workspaceId
- trigger: trigger / sourceMessageId / defaultTaskAssigneeAgentId / signal (was `agentSignal`) / continuation (was `agentInterventionContinuation`)
- run tree: `lineage.{isSubAgent, orchestrationRole, parentOperationId, progressAnchor}` (was `subAgentProgress`)

`normalizeAgentState` is generalised to a key → slot-path table and lifts the legacy keys on state load, treating `null` legacy values as absent so the slots only ever hold `undefined`. `RuntimeExecutorContext`, `resolveRunActiveDeviceId` and the lifecycle helpers read `state.origin`; `CompletionLifecycle` threads the origin alongside the remaining metadata; `parseAgentSignalMarker` validates a raw marker so the completion payload extractor can read `origin.signal` while operation rows keep `metadata.agentSignal`. Audit facts (`clientIp`, `userAgent`) and the runtime-written intervention preparation stay in `metadata` for the principal / ledger slots.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:10:03 +08:00
Arvin Xu 3df8460895 ✨ feat(goal): let the system planner lead and hand problems to the main Agent (#19477)
* ✨ feat(goal): let the system planner lead and hand problems to the main Agent

Configuring a main Agent used to replace the coordinator's own planning. `tick`
asked it first and returned early, so on a Goal that had both, the exploration
planner never got a turn — and `goal.create` rejected the combination outright
("Manager mode owns initial and subsequent planning").

The two are layers, not rivals, and the split is visible in their vocabularies.
Exploration says `expand | revise | verify`: it can move within the graph but
cannot express "this goal is mis-framed", "the judge was wrong" or "stop and ask".
A main Agent says `tasks | verify | retry | escalate`, which is the supervisory
half. Meanwhile the main Agent was only ever consulted about failures that passed
the transport whitelist, so a Goal could park on its owner for hours with a main
Agent configured and idle.

Invert the order:

- The system planner owns the ordinary path. With exploration configured, `advance`
  only settles a turn already in flight — a turn already paid for has to land —
  and starts no new one. A Goal whose only planner is the main Agent is still led
  by it.
- `gateOrTakeOver` replaces the three `openFailureDecision` call sites. Every
  stop-on-a-person funnels through there, and the coordinator only reaches it once
  its own policy is out of moves: the failure matched no recovery branch, the
  attempt budget ran out, or the reason could not be classified. That is exactly
  the class the deterministic lane cannot express, so it is handed over. The ladder
  is supervision, then the main Agent, then the Gate.
- An invited turn skips the "is there unfinished work" and "is this a recognised
  transport failure" narrowings. Those ask whether an *uninvited* turn should
  interrupt running work; the caller has already answered a harder question. Turn
  limits, budgets and the compare-and-swap claim still hold.
- An invited turn with no turn budget left declines instead of pausing the Goal, so
  the Gate keeps carrying the real question instead of "the main Agent is out of
  turns".
- The create-time exclusivity check is gone: exploration, supervision and seed
  tasks may now accompany a main Agent. Ordering resolves what exclusivity did.

Prompt cohort goes to v4: a takeover turn is told which problem it inherited and
that the Goal stops on a person without it, so `escalate` means "a human really is
required" rather than being the only reachable answer.

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

* 🐛 fix(goal): keep supervision first and turn a takeover escalation into its Gate

Two P1s from Codex review, both caused by the combinations this PR newly allows.

**Supervision was losing its place in the ladder.** With `manager` + `supervision`
and no `exploration`, `mayStartTurn` is true, so an uninvited turn claimed a
recognised transport failure at the top of `tick` and returned early —
`decideNextMove` never reached `failure_decision`, and `reviewFailure` never ran.
That reverses the documented order, spends a planning turn on a failure the
supervisor recovers by itself, and skips the supervisor's stricter
actor-authored-status check. An uninvited turn now stands down from failures
entirely when supervision is enabled; whatever the supervisor declines arrives at
`gateOrTakeOver`, which invites the Agent properly.

**An escalation had nothing to escalate to.** The takeover contract says
`escalate` puts the Gate back one turn later, but the escalate branch only paused
the Goal and recorded a status event — no Decision, no answerable question, and
every later tick sat on `goal_paused`. An escalation from a takeover turn no longer
touches the Goal's status, so the next tick reaches the same handover point; there
`takeOver` declines a problem its own escalation already answered and the Gate
opens, carrying the Agent's reason in the question. An ordinary planning turn's
escalation still pauses, because nothing is holding a Gate behind it.

`managerState.problem` records what a turn was invited for. It is what separates a
takeover turn from ordinary planning, and what keeps the same problem from being
handed over twice.

Both regressions are pinned: reverting either fix turns its test red.

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

* 🐛 fix(goal): let a takeover commit the moves it was offered, keyed per task

Codex review round 2. Both findings are the takeover contract promising more than
the code accepts.

**Three of the four advertised actions could not commit.** `submit` refuses `tasks`
and `verify` whenever any task node is unfinished — which a takeover's inherited
task always is — and `retry` is refused for anything outside the transport
whitelist. So the prompt offered four moves and only `escalate` could ever land.
The unfinished-work guard exists to stop an *uninvited* turn from double planning
the frontier; a takeover's inherited work is stuck by definition, which is why it
was handed over, so a corrective task is the answer rather than the thing to
forbid. The guard is now waived for an invited turn, and the prompt stops
advertising a retry for failures that will be refused.

**The already-answered key was too coarse.** "Task attempt budget was exhausted" is
the same sentence for every task that reaches it, so keying on the reason alone let
a second task skip its own takeover and inherit the first task's diagnosis in its
gate — resolving the first gate does not clear `managerState`. The key is now task
and reason together, through a shared `problemKey` so the writer and the reader
cannot drift.

While there: any committed plan now counts as an answer, not just `escalate`. If
the Agent's move did not unstick the Goal, handing the same problem back buys the
same move; the Gate is the honest next step and the Agent's reasoning rides on it.

Both regressions are pinned: reverting either fix turns its test red.

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

* 🐛 fix(goal): retire the work a takeover plan replaces

Codex review round 3, and it is right that the previous fix only moved the wall.
Accepting `tasks` and `verify` on an invited turn changed validation and nothing
else: the inherited task node stayed nonterminal, so the next tick's frontier
reached that older blocked node before the freshly created corrective node and
routed straight back to `failure_decision`, while terminal verification could not
start at all while it was unfinished. The advertised actions committed and the Goal
still fell through to the human Gate.

A `tasks` or `verify` plan from a takeover turn now retires the node it inherited.
That is the same move the Gate offers a person under "Retire task", scoped to the
single node this turn was invited about, attributed to the main Agent, and only
reachable on an invited turn. It abandons a direction rather than re-spending on
it, so it stays clear of the open question about who may pay for another attempt.

`managerState.problemTaskId` carries the blocked task alongside the dedupe key, so
the node is found without parsing a composite string whose reason half is free
text.

Pinned: the test ticks past the submission and asserts the replaced node is retired
and the Goal does not land on a Gate. Reverting the retire turns it red.

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

* 🐛 fix(goal): hand a failed terminal acceptance to the main Agent too

Codex review round 4, and it is the same species as the previous three: a guard
written for uninvited turns sitting above the invited branch.

`startTurn` returns null whenever a terminal acceptance task exists on the graph.
That rule means "verification exists, stop planning more work", which is right for
an uninvited turn and exactly wrong for a takeover invited BECAUSE the terminal
acceptance is the thing that failed. So a Goal with a main Agent configured still
reached a bare human Gate on the one failure where a second opinion matters most.
The guard now lives inside the uninvited branch with the others.

The retirement added in the previous commit deliberately does NOT apply to the
acceptance node. `decideWithoutFrontier` finds that task by TITLE regardless of
status, so retiring it parks the Goal on `no_progress` with neither a Gate nor a
verdict — and failing the Goal is the Gate's own `retire` answer, coupled to that
option rather than to the node's status. A takeover on a failed acceptance can
therefore diagnose an incorrect judge and put that reasoning on the Gate, or plan
corrective work; making corrective work supersede a failed acceptance by itself
needs the acceptance lifecycle to allow superseding, and that is a separate change.
Both limits are stated in the README rather than left for the next reviewer.

Pinned: one test asserts the takeover starts for a failed acceptance with no Gate
opened, another asserts the acceptance node is never retired. Reverting either half
turns its test red.

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

* 🐛 fix(goal): refuse takeover plans that would strand the graph

Codex review round 5. Two of its three findings are defects in what the previous
commits shipped; the third is a real gap that this PR does not close, and the README
now says so instead of leaving it for the next reviewer.

**Retiring a node something depends on strands the dependent.** A prerequisite counts
as met only when it is `resolved`, so a retired predecessor blocks its dependents
forever and the Goal lands on `no_frontier`. Rewiring them onto the replacement would
be the answer, but the graph model has no edge removal, so the old `depends_on` edge
would keep pointing at the retired node. Retirement is refused when the stuck node has
dependents, and that block goes to the Gate.

**A failed Goal acceptance cannot be superseded, so stop accepting plans that try.**
The acceptance task is matched by TITLE regardless of status: a corrective task returns
to the same failed node, and `verify` sets `readyForAcceptance` without producing a
fresh run. Both submissions used to be accepted and then strand. `tasks` and `verify`
are now refused for an acceptance takeover, and the prompt names that limit together
with the dependents one, so the offer and the answer are the same sentence and a turn
is not spent discovering the difference.

**Not closed: exploration's own stop conditions.** The experiment limit, a spent
revision allowance and a planner error all pause the Goal directly rather than passing
through `gateOrTakeOver`, and `eligible()` then rejects the paused Goal — so the
planner whose "out of moves" most deserves a second opinion never reaches the main
Agent. Closing it requires an invited turn to revive a Goal the system paused, which
changes who may restart one; that belongs with the open question about who may pay for
another attempt, not in this PR.

Both fixes are pinned: reverting either turns its test red.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:01:29 +08:00
Arvin Xu 73a0528546 ♻️ refactor(agent-runtime): carve typed world/binding slots out of state.metadata (#19498)
* ♻️ refactor(agent-runtime): carve typed world/binding slots out of state.metadata

Every reader of the prompt-side run context (agent config, group roster, project instructions, connector note, user memory/timezone, search decision, channel facts, eval context) and of the device binding (active device id/platform/system info) used to reach into the untyped `state.metadata` bag, so a value that was not spelled out in the creation-time field list silently disappeared.

`AgentState` now carries two typed slots derived from what those fields mean:

- `world` — what the model is told about the run's world. Frozen at operation creation; the context engine only reads it.
- `binding` — late-bound execution facts. The one business slot the host may rewrite at a step boundary (`computeDeviceContext`).

`normalizeAgentState` lifts the legacy metadata keys into the slots at the persistence boundary (Redis + in-memory state load) so blobs written before this change keep working, without carrying the data twice. `RuntimeExecutorContext` drops its per-field copies; readers go through `state.world` / `state.binding`, and `resolveRunActiveDeviceId` takes the state instead of the metadata bag.

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

* ♻️ refactor(agent-runtime): read the trace header model from world.agent

The trace recorder still reached into `metadata.agentConfig` through an `as any` cast, which the key grep missed.

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

* ♻️ refactor(agent-runtime): declare the search decision shape instead of importing model-bank

The runtime package should not take a dependency on the model catalog for one five-field interface. `SearchDecisionSnapshot` is declared next to the world slot and is structurally identical to the `model-bank` resolver output, so the server keeps passing its value unchanged.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:34:35 +08:00
Arvin Xu 7712888372 ⚡️ perf(agent): run consecutive agent steps in one invocation (#19470)
* ⚡️ perf(agent): run consecutive steps in one invocation

Every step boundary cost a full queue round-trip: ~2.9s at p50 across
production traces, with nothing under 2s. That is 13.6% of all agent-run
wall time, plus another 8.4% for the same hop before step 0, and it rises
past a quarter of wall time on multi-step operations.

The QStash worker now keeps stepping inside one invocation for as long as
the operation has a next step ready and its time budget allows. Past the
budget the pending step goes back to the queue and a fresh invocation
picks it up, so the tail behaves exactly as it does today.

- `executeStep` takes `inlineContinuation` and hands the next step back as
  a `continuation` instead of publishing it. Every terminal outcome and
  every park (waiting_for_human, pending approval, async-tool wait) leaves
  it absent, so the loop stops on its own without special-casing them.
- One lock owner spans the whole loop. The operation lock is now re-entrant
  for the owner that already holds it; owner tokens carry a random UUID, so
  a redelivery from the queue still loses the race. The lock is no longer
  dropped and re-claimed at each boundary.
- Only the first step carries the delivery's one-shot payload. Inlined
  steps must not replay a human input or an approval that already ran.
- The existing `stepCount > stepIndex` stale-delivery guard already covers
  a redelivery arriving after the loop advanced past its step.

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

* 🔧 chore(agent): set the inline step deadline to 450s

Leaves ~150s of headroom under the route's 600s maxDuration, which covers
the p99 LLM step (~125s), so a step that starts just under the deadline
still finishes inside the invocation.

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

* 🐛 fix(agent): park the inline step envelope so a dead loop can recover

Codex review caught a failure mode the loop introduced. Today a worker
that dies mid-step recovers: state never advanced, so QStash redelivers
the same step and it runs again. With the loop, a worker that dies during
step N+2 leaves state at N+2 while the only queued message is still the
original step N. The `stepCount > stepIndex` stale-delivery guard ACKs
that redelivery, and the operation is stranded with nothing queued.

The loop deliberately does not publish the next step, so the envelope it
would have published is parked in Redis instead, written before the step
runs. A redelivery that lands behind a parked envelope resumes from the
envelope rather than from its own (older) index, which is the same
recovery the queue used to provide.

- `agent_runtime_inline_resume:{operationId}` holds the envelope under the
  existing 2h operation TTL, and is dropped with the other operation keys.
- Cleared when a step ends without a continuation, and when the deadline
  hands the pending step back to the queue — in both cases something else
  owns the next step and a lingering pointer could re-run it.
- A resumed run carries none of the delivery's one-shot payload: it stands
  in for a later iteration of the dead loop, not for the original message.
- Read failures degrade to null; without an envelope the handler just runs
  the step it was given.

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

* 🐛 fix(agent): fall back to the queue when the inline envelope can't be parked

`saveInlineResume` swallowed its Redis error, so a failed park still
inlined the next step — with no envelope and no queue message behind it,
which is exactly the stranding the envelope exists to prevent.

It now reports failure and inlining is conditional on the park succeeding.
When Redis is unavailable the step takes the ordinary queue round-trip:
one step boundary slower, but the recovery path stays intact.

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

* ✨ feat(agent): gate the inline step loop behind a runtime feature flag

Makes the loop a rollout switch rather than a deploy-time behaviour change,
so it can be turned on for a slice of traffic and turned off again without
shipping anything.

Uses the existing RuntimeConfig path rather than a new mechanism: the flag
`inline_agent_steps` joins `FeatureFlagsSchema`, resolves through
`getServerFeatureFlagsStateFromRuntimeConfig` (Redis-backed, ~5s in-process
cache), and accepts either a boolean or a user-ID allowlist like `dev_dock`
and `agent_share` do. Develop Center already writes that snapshot.

Default is off, so merging and deploying this changes nothing until the
flag is published.

- The gate fails closed: any error resolving it falls back to one step per
  delivery.
- Looking up a parked envelope is deliberately NOT gated. Operations that
  were mid-loop when the flag flipped off have an envelope and nothing
  queued behind them, so ignoring it would strand exactly the runs a
  rollback is meant to protect.
- Envelope clearing moves from `executeStep` to the handler, which is the
  only place that knows the invocation is done with it — including the
  flag-off resume path, which `executeStep` no longer sees as inline.

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

* 🐛 fix(agent): keep the retry count and surface envelope read failures

Two more findings from Codex review, both about the inline loop's recovery
path silently turning a retryable delivery into a dead end.

Preserve `externalRetryCount` when resuming from a parked envelope. A step
that parked for approval but failed to persist its Review is only replayed
while the count is nonzero (`shouldReplayPendingInterventionLifecycle`).
The resume path dropped it along with the one-shot payload, so the stale
guard ACKed the step and the approval never became available. The count
describes the delivery, not the step's payload, so it belongs on both paths.

Surface a failed envelope read instead of reporting "no envelope". The two
have opposite consequences: absent means run the delivered step, unreadable
may mean an operation is mid-loop with nothing queued behind it. Collapsing
them ACKed that delivery as stale and stranded the run. The read now
propagates and the queue retries.

An unparseable envelope stays non-fatal — retrying cannot fix it, so it is
reported and treated as absent rather than looping into the DLQ. Clearing
stays best-effort: a stale envelope only resumes a step ahead of the
delivered index, and the next park overwrites it.

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

* 🐛 fix(agent): only redirect plain step deliveries to a parked envelope

Self-review finding. The envelope lookup keyed purely on
`parked.stepIndex > stepIndex`, regardless of what the delivery carried.

That silently rewrites deliveries that exist for their own reason —
approvals, async-tool resumes, barrier probes, member-timeout watchdogs —
into ordinary step runs with their payload dropped. The group-member
timeout is the sharp edge: `scheduleGroupMemberTimeout` publishes it on the
member operation at `stepIndex: 0`, so ANY envelope is "ahead" of it. A
member that is mid-loop always has one, so the watchdog would be swallowed
and the parked supervisor would wait forever.

It compounds: the hijacked delivery then loses the lock race against the
loop that is still running, and on its way out clears that loop's envelope
— removing the recovery pointer from a live operation.

The lookup is now skipped entirely unless the delivery is a plain "run the
next step" message, which also saves a Redis read on every watchdog and
intervention delivery.

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

* 🐛 fix(agent): clear the inline envelope only while holding the lock

Codex review. Clearing was an unconditional DEL on the operation's envelope
key, which lets a worker delete a pointer it does not own.

A redelivery can read envelope k, stall while the active worker finishes k
and parks k+1, then lose the lock race and be told its step is stale. On the
way out it cleared the key — deleting the live worker's newer envelope. If
that worker then died during k+1, its own redelivery would hit the stale
guard with no pointer to resume from, stranding the operation.

Ownership of the operation lock is the right predicate: whoever holds it is
the worker whose envelope this is. Clearing is now a two-key Lua compare
against the lock's owner token, mirroring the existing release script, so a
worker that lost the race no-ops instead of deleting.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:52:56 +08:00
Arvin Xu abdd7ff74f ♻️ refactor(context-engine): let the engine assemble the system message (#19485)
* ♻️ refactor(context-engine): let the engine assemble the system message

The server composed the final system prompt by string-appending onto
`agentConfig.systemRole` from three files across the pipeline: the resolver,
`operationPrep` (project instructions), and `toolDiscovery` (borrowed-connector
attribution). That is why the resolved config had to stay mutable all the way
through, and why each append site carried a comment vouching for the aliasing —
"appending here still reaches the LLM because `createOperation` reads
`agentConfig` below". Correctness depended on nobody cloning the object, which
the `do not clone` warning enforced by convention rather than by type.

The mechanism for doing this properly already existed and was being bypassed.
`BaseSystemRoleProvider` is purpose-built — subclasses return content, the base
finds or creates the system message and joins with `\n\n` — and the engine has a
declared "Phase 2: System Message Assembly" where a dozen injectors already use
it.

So the two late contributions become injectors. `ProjectInstructionsInjector`
takes the files rather than a rendered block, because wrapping them is
presentation and belongs to whoever owns the system message.
`ConnectorOwnershipInjector` takes a rendered note, because naming the
authorizers needs a user lookup the engine cannot perform — the same split
`EvalContextSystemInjector` makes with `envPrompt`.

Both run immediately after `SystemRoleInjector`, which is exactly where their
text used to land. Placing them later would silently reorder the prompt behind
every other Phase 2 provider; keeping the position keeps it byte-identical.

The data reaches the engine on `AgentConfigWithId`, which already carries
run-scoped fields stamped by one stage for another (`subAgentChatConfigOverride`).
Same transport as before, structured payload instead of a concatenated string —
no new plumbing through the state blob, and no size change, since the same text
already travelled inside `systemRole`.

Not included: the persona-level appends inside the resolver (builtin runtime
role, page/task agent, per-call instructions). Those compose what the agent IS,
which the resolve phase owns; these two are run context, which the engine owns.

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

* ♻️ refactor(agent): carry system-message context on the operation, not the config

Follow-up to the previous commit, which moved the two late system-prompt
contributions into context-engine injectors but carried their payloads on
`AgentConfigWithId`. That was the same category error in a better shape: it
removed the string concatenation and kept smuggling run context through the
agent's configuration. Project instructions come from the workspace and the
connector note from tool discovery — neither is part of what the agent IS.

They now travel the way every other piece of run context does. There is an
established family of them on the operation metadata — `agentShareVisitor`,
`botContext`, `botPlatformContext`, `discordContext`, `userTimezone`,
`evalContext` — each mapped onto `RuntimeExecutorContext` and consumed
downstream, and `evalContext` in particular already feeds an injector.

`expertise` is the exact precedent and this follows it end to end:
`prepareOperation` and `discoverTools` return the values instead of mutating
the config, `startOperation` puts them on the operation, `createOperation`
persists them into `state.metadata`, `AgentRuntimeService` maps them onto the
executor context, and the context builder reads them from there.

Persisting matters rather than being incidental: steps can be claimed by
different workers, so anything the engine needs has to survive on the
operation. `createOperation` builds `state.metadata` from an explicit field
list, so a value not named there is dropped without a word — the same shape of
trap as the zod schema that silently stripped `createdAt`.

The two fields added to `AgentConfigWithId` in the previous commit are removed.

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

* 🐛 fix(agent): forward the system-message context, and restore its order

Two from review, and the first one meant the whole change was dead on the
server.

`serverMessagesEngine` destructures a fixed parameter list, so the two new
fields never reached `MessagesEngine` and both injectors always saw
`undefined`. The old concatenation was already gone, so every server run would
have lost its project instructions and connector attribution outright —
`AGENTS.md` silently absent from the prompt. Neither the injector unit tests
nor the type checker caught it: the tests exercised the injectors directly,
and the call site passes an intermediate object, which disables
excess-property checking.

So the regression test lives at the wrapper-to-engine seam, not on the
injectors. Verified it fails without the forwarding.

The injector order was also wrong. `discoverTools` runs before
`prepareOperation`, so the old appends produced persona → connector note →
project instructions; the injectors had them the other way round, against an
adjacent comment claiming the prompt was byte-identical. Connector attribution
now precedes the project instructions, with a test pinning it.

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

* ✅ test(agent): cover every link the system-message context travels

The forwarding bug shipped with green injector tests because they exercised
the injectors directly. The wrapper test added with the fix only covers the
last link; a value dropped anywhere earlier is just as silent and just as
total.

Every failure this feature has had — the wrapper's fixed parameter list, and
`createOperation` assembling `state.metadata` from an explicit field list —
was a name missing from a list rather than broken logic. Nothing about that
shape surfaces in behaviour: the injectors keep working, they simply never
receive anything. So each link now has an assertion of its own, and each was
verified to fail with its forwarding removed:

  createOperation params → state.metadata   (AgentRuntimeService.test)
  executor context       → engine input     (serverCallLlmContextBuilder.test)
  engine input           → system message   (serverMessagesEngine.test)

One link is still covered by reading rather than by a test: `state.metadata` →
executor context. And a true end-to-end run through `execAgent` is
disproportionately expensive here, because the project instructions originate
in the device workspace-scan path — reaching them needs an activeDeviceId, a
device row and a scan result, none of which is the thing under test.

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

* ✅ test(agent): pin the stage order the system message depends on

Drives the real `execAgent` pipeline and asserts that tool discovery runs
before operation prep. That order is the reason connector attribution precedes
the project instructions in the assembled system message, and until now it
rested on a comment citing two line numbers — the same kind of claim that was
wrong the first time this ordering was written down.

Carrying a real payload end to end through this harness turned out to cost
more than it returns. The project instructions originate in the device
workspace-scan path, so reaching them needs an active device, a device row, a
fresh cached scan, and the agent-skill lookup that happens to share the same
`try` — each mock revealing the next, and none of it the thing under test. The
payload is therefore covered link by link instead, which also matches the
failure shape: every regression it has had was a name missing from an explicit
field list, not broken logic.

Worth recording separately: project instructions and the project skill lookup
share one `try`, so a skill-model failure silently drops the instructions too.
Pre-existing, unrelated to this change, and not fixed here.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:36:03 +08:00
Innei ef6e3b1fe5 ✨ feat(desktop): add built-in completion sound choices (#19480)
* ✨ feat(desktop): add built-in completion sound choices

Ship three more synthesized chimes next to the marimba default (glass bell,
soft tone, xylophone) and let the notification settings pick one from a
select; importing a sound now lives inside that select and picking a
built-in drops the imported pack. The generator script renders every
built-in from one instrument × motif table; the default output stays
byte-identical.

Dev builds no longer compute the renderer main hash unless OTA is forced,
since the lockfile pin check only matters for packaged builds.

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

* ♻️ refactor(desktop): synthesize completion sounds at build time

Move the chime renderer out of a one-off script into a Vite plugin that
writes the WAVs under the renderer public dir and the banner AIFF under the
desktop resources on every dev/build start, and stop tracking the audio
binaries. Output is byte-identical to the files that were checked in; a
test pins the default chime's hash.

Claude-Session: https://claude.ai/code/session_01Rf3vs7eC8jwF75kohcS3Ye
2026-09-12 21:54:05 +08:00
Innei 4af3510d81 📦 build(desktop): package only target-platform native prebuilds and drop .pdb (#19490)
* 📦 build(desktop): package only target-platform native prebuilds and drop .pdb

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

* 🔥 chore(desktop): drop module-deps config test

Claude-Session: https://claude.ai/code/session_01EBaC7yuAyG4Lo4aDg3L43i
2026-09-12 21:33:30 +08:00
Arvin Xu dc67b41643 🧹 chore: drop LOBE-XXX issue markers from code comments (2026-09-11) (#19418) 2026-09-12 19:41:34 +08:00
Innei bf938ff524 🐛 fix(desktop): skip the main hash in Vite development mode (#19486)
resolveMainHash walked the full source graph on every dev config load and
failed whenever root node_modules drifted from the Desktop lockfile. The
runtime already disables renderer OTA when MAIN_HASH is empty, so dev builds
now resolve to an empty hash unless MAIN_HASH is set explicitly.

Claude-Session: https://claude.ai/code/session_01DjXmqHj7t17UfwNuXMjfn3
2026-09-12 18:58:53 +08:00
Innei 92c2452d53 🐛 fix(desktop): canonicalize main hash graph paths (#19484) 2026-09-12 18:41:40 +08:00
Arvin Xu ab5ec4c81d ♻️ refactor(agent-share): move the share surface out of the open-source tree (#19467)
* ♻️ refactor(agent-share): move the share surface out of the open-source tree

Agent sharing runs a visitor's conversation on the creator's account and bills
it to them. `ENABLE_BUSINESS_FEATURES` is `false` here, so every entry point
already failed closed and the ~6k lines of surface were unreachable — while the
spend cap that makes sharing safe is enforced only where that accounting lives.
Its siblings `pageShare` and `artifactShare` already ship that way.

Removed: the visitor and creator-settings features, their skeletons, and the
`/a/*` route registrations on desktop and mobile.

Kept: the table, models, tRPC procedures, runtime authorization, and the
locale keys. Only the UI layer moves.

Added two slots so the remaining open-source surfaces keep compiling and stay
honest about the capability:

- `useAgentShareSupported` reports "not supported", which hides the profile
  tab, the header action, and the settings page.
- `AgentShareSettingsPage` renders nothing for the route those entries reach.

`SharedAgentData` also gains the fields the moved profile reads — creator,
tags, opening questions, reach counts, terms and granted tool identifiers —
since the procedure serving them stays here.

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

* ✅ test(spa): stop asserting the share path as a standalone route

`/a/*` is no longer defined here, so it resolves through the main layout's
catch-all instead of as a standalone route. The deployment that registers the
share surface asserts its own scope.

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

* 🔥 test(e2e): drop the agent-share keyboard scenario with its surface

The `@ROUTES-003` scenario tabs to the share visitor top bar's "Go to my
LobeHub" link, which left this tree along with the rest of the visitor
UI. Its intent — a share link never dead-ends a visitor — is covered in
the cloud repo, where the bar now lives.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 18:02:53 +08:00
Arvin Xu 2141e9917b 💄 style(acceptance): name the author on the record and drop the shared-link banner (#19478)
* 💄 style(acceptance): name the author on the record and drop the shared-link banner

The acceptance header now carries who delivered the work, the way a pull
request names its author: avatar and name sit right after the status, ahead
of the counts. `getBundle` resolves that profile from the acceptance's own
owner, so a visitor holding nothing but the shared link still sees whose
record they are reading.

With the name on the record, the standing "shared with you" banner had
nothing left to say that the page did not already show, so it is gone —
component, both mount points, and its five locale keys.

The per-check entry beside the status was a small button wearing a 44px
floor, which drew it as a block twice the height of the row it lives on.
Dropping the override lets it sit inline at its own size.

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

* 💄 style(acceptance): lead the header with the delivery's name, then one bar

Review feedback on the first round: the title should come before the status,
the pass/uncertain tally beside it should go, and the origin chips sitting on
their own row underneath should join the same bar.

So the name leads, and one bar under it carries state, author, the origin
agent, the originating conversation, the pull request, and the per-check
entry. The header goes from three rows to two.

The tally and the last-run time are dropped outright. They change every round
and read as a verdict while sitting above the checklist that actually shows
them, so `acceptance.verdict.latestAt` goes with them.

With both avatars now side by side in one bar, an origin agent without a
picture drew the component's "UN" placeholder next to a real face. It falls
back to its own initial, the way the author's does.

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

* 💄 style(acceptance): trail the title with the per-check entry, on hover

Review feedback on the second round: the entry reads cleaner after the name
than inside the meta bar, and it only needs to be there when reached for.

So it moves out of the bar to sit after the title, and fades in when the row
is hovered. It fades rather than hides: an entry that appears by reflow would
retitle the row under the pointer, and `visibility: hidden` drops the button
from the tab order and the accessibility tree, which was measurable — with it,
`btn.focus()` left `document.activeElement` elsewhere and the `:focus-within`
rule could never fire. `pointer-events` keeps the faded box from swallowing
clicks meant for the title. Touch never hovers, and the button already removes
itself below the mobile breakpoint, so nothing is stranded there.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 17:46:33 +08:00
Innei f4d21a6e1e ✨ feat(portal): copy outside-workspace publish assets into the workspace (#19447)
- add a `copyAssetForPublish` transport (device-control default, desktop IPC
  gated by the external-file approval path, device RPC + gateway with
  workspace containment on the destination) so the outside-workspace
  publish guide can relocate assets on every runtime
- build `copyFile` / `writeFile` for local, device and sandbox files
- let `useBlockedWorkspaceHtmlPublish` hand the confirmed plan to a caller-
  supplied `runPublish` instead of always publishing inline
- show a loader instead of spinning the action icon while a toolbar action is busy

Claude-Session: https://claude.ai/code/session_019jVhraxkznG9MtkXaux657
2026-09-12 15:25:23 +08:00
Innei 6b85e332f1 🐛 fix(desktop): pin bundled npm deps through the Desktop lockfile (#19462)
* 🐛 fix(desktop): pin bundled npm deps through the Desktop lockfile

The main hash accepted any npm module that happened to resolve inside apps/desktop/node_modules, so a module linked from outside the frozen install (or from a stale store entry) could enter the renderer OTA hash without the lockfile ever pinning it.

Resolve each bundled module to its package name@version and require apps/desktop/pnpm-lock.yaml to pin it, keeping the file:/link:/git/tarball escape hatch for specifiers that pin by resolution instead of version.

partial-json is imported by packages/utils and packages/context-engine but was never declared, so the Desktop lockfile could not pin it: declare it in both packages and record it in apps/desktop/pnpm-lock.yaml.

* 🔊 chore(desktop): name the changed bundles before a dev restart

The dev orchestrator logged only that a main/preload bundle changed, which cannot distinguish the debounced restart from an unrelated vite rebuild. Collect every file reported by the watcher during the debounce window and print it with its event type, size and mtime.

* 📝 docs(acceptance): publish the round from the session's own observations

A delivery verified on the real product earlier in the session still had to re-run the plan and checker stage to obtain a published round. Document that those observations and artifacts are the evidence: write plan[] and cases[] from them, disclose anything never captured, and ingest.
2026-09-12 12:35:36 +08:00
Arvin Xu 5aa56d8992 🐛 fix(verify): stop the acceptance review from rejecting evidence it never saw (#19468)
* 🐛 fix(verify): share the review evidence budget so one attachment cannot starve the rest

The review predictor spent its 60k-character text budget in capture order and
broke out of the loop once it ran dry, so a large attachment silently consumed
everything behind it. On a real Goal delivery the four rows were 31,853
characters of script source, a 4,307-character rerun log, a 52,226-character
score dump and a 1,319-character summary: the summary never reached the prompt
at all, and the reviewer rejected the check for missing evidence that had in
fact been attached.

Allocate by water-filling instead — walk the rows smallest first and grant each
an equal share of what is still unspent, so every row is guaranteed
`budget / rowCount` characters and small rows stay whole.

Two related gaps in the same block:

- A text row's own description was dropped, while visual rows had always carried
  theirs. The caption is often the only thing that says what a payload is
  ("rerun log, byte-identical to round 1"), so the reviewer was left inferring
  from raw bytes.
- Truncation was applied silently. A payload that stops mid-line reads as a
  corrupt artifact, which invites exactly the "insufficient evidence" rejection
  the truncation caused. Truncation and omission are now stated in the text.

The allocation moved to its own module so the rule is testable without a
database; content resolution for every candidate row now happens before
allocation, which is the cost of being able to size them fairly.

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

* ✨ feat(verify): let the acceptance review report a criterion it cannot decide

The automated review only had `accept` and `reject`, so "this delivery fell
short" and "no reader could settle this check" were the same answer. Criteria
that ask the reviewer to act on the system — rerun the delivered scripts, build
the project, drive something live — are undecidable from captured evidence by
construction, and the hardened v3 prompt correctly turns "no affirmative proof"
into a reject. The result is a Goal Task that re-delivers the same artifacts
against the same unprovable criterion until its attempt budget runs out.

Add `unjudgeable` as a third verdict:

- The prompt defines it by one test: could some capture the builder is able to
  produce settle this check? If yes it is a reject and the comment says what to
  capture. Only if no capture could ever settle it is it `unjudgeable`. Thin,
  blank, truncated or unconvincing evidence stays a reject, so this is not an
  escape hatch from the leniency the prompt was hardened against.
- `reviewGoalDelivery` keeps it out of the rejected bucket and aggregates by
  precedence rather than last-writer-wins, since checks are judged concurrently.
  A genuine rejection outranks it: one short check still makes another attempt
  worth paying for.
- A task whose review could not decide pauses with a new contract string that
  deliberately has NO recovery branch, so the coordinator routes it to a person
  on the first occurrence instead of buying another identical attempt.

The action vocabulary lives twice — the model's JSON schema and the column it is
stored in — so a test now pins the two lists to each other. The column enum is
type-level only in drizzle, so no migration is needed.

`REVIEW_PREDICT_PROMPT_VERSION` goes to v4 because agreement statistics are
grouped by (provider, model, promptVersion); the new cohort covers both this
change and the evidence-budget change it ships with. Existing v3 rows stay
attributable, and open acceptances re-judge on the next request.

Out of scope, stated for the reviewer: the acceptance page still renders no card
for an `unjudgeable` prediction (only rejects surface), and nothing yet escalates
such a check to a judge that can execute.

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

* 🐛 fix(verify): name the trigger that makes a criterion undecidable

Probing the pinned review model on a criterion that explicitly reads "the
reviewer must log in and compare the memory curve themselves", the model still
answered `reject` with "no monitoring evidence attached" — it read an action it
cannot perform as evidence somebody forgot to capture.

Naming the trigger fixes it: a criterion that makes the REVIEWER the actor
cannot be settled by any capture, because the reviewer cannot become that actor.
Verified against gemini-3.6-flash: that criterion now returns `unjudgeable`,
while a check whose evidence is merely absent still returns `reject`.

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

* 🐛 fix(verify): tell the review which artifacts it was not shown

The review request silently carries less than the check does: frames past
`MAX_VISUALS` are dropped, audio and video are never read, and a text row whose
payload cannot be resolved simply vanishes. The prompt then demands affirmative
proof, so the model answers "not observable in the evidence" about artifacts that
exist and were withheld from it — and the comment tells the builder to recapture
something already captured.

Driven against a real check that carries five verification screenshots, with the
pinned review model and the real frames:

| Withheld declared | Run 1 | Run 2 |
| --- | --- | --- |
| no (current behaviour) | reject: "缺少单元 U208 和 U223 的截图原图" | reject, same two units |
| yes | accept | accept |

Both rejections name precisely the two frames the request itself removed.

Declare them instead. `describeWithheldEvidence` lists what the request held back
— frames past the cap, media this reviewer cannot open, payloads it could not
resolve — with each artifact's own caption, because the caption is the only handle
the model has on something it cannot open. The prompt gains one rule: a withheld
artifact is never cited as missing, and a verdict that turns on one is
`unjudgeable` rather than a reject.

The caveat is also persisted on the judged row. "Rejected having seen everything"
and "rejected while two frames were held back" are otherwise the same verdict in
the agreement statistics, which is exactly the distinction the review-quality work
needs. `status_reason` is redocumented to cover that.

Scope on this account's labelled history: 50 of 1861 checks carried more frames
than the request attaches, and 6 carried audio or video the reviewer never reads.

One read of the check's evidence now feeds all three decisions — which frames to
attach, which payloads to inline, and what was held back — so they cannot disagree
about what the check carries. Prompt cohort goes to v5.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:23:37 +08:00
Tsuki 3e47ca7364 🐛 fix: isolate execution settings per topic (#19445)
* 🐛 fix: isolate execution settings per topic

* 💄 style: order topic type imports

* 🐛 fix: preserve execution metadata in topic creation

* ✅ test: align execution fixtures with topic isolation
2026-09-11 20:17:51 -07:00
Arvin Xu ffedec8f6d ✨ feat(device-control): list one project directory on demand (#19460)
* ✨ feat(device-control): list one project directory on demand

The project index asks Git for ignored paths with `ls-files --directory`,
which reports a fully ignored folder as a single entry. Its children are
never enumerated, so a caller rendering that entry as an expandable row
has nothing to show.

Mark those entries `collapsed` and add `listProjectDirectory`, which reads
one level of a directory from disk. Callers can then fill a subtree only
when someone opens it, so an ignored folder still costs nothing until then
and `node_modules` is never walked.

The flag is only set when the directory truly has no indexed children — a
nested `.gitignore` that re-includes part of the subtree leaves those rows
complete, and they stay unflagged.

Wired for both transports: an Electron IPC method on the desktop
controller, and a device RPC plus TRPC procedure for remote devices. Also
adds `trashLocalFiles`, which moves paths to the OS trash via
`shell.trashItem` rather than unlinking them.

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

* 🔒 fix(device-control): resolve symlinks before enforcing project containment

`path.resolve` is lexical, so a symlink that lives inside the project but
points outside it passed the prefix check and then had its target
enumerated. Verified against a real temp fixture: listing a link to an
outside directory returned that directory's files.

Check containment on the real paths of both the root and the target.
Entries are still built from the lexical path, so their ids stay anchored
to the path the caller expanded rather than wherever the link resolved —
a link that stays inside the project keeps working and keeps its row.

Also return per-path outcomes from `trashLocalFiles`. A batch is not
atomic, so bailing out on the first failure left the caller unable to
tell which earlier paths were already in the trash, and therefore unable
to refresh its tree or retry only what was left. This matches the shape
`moveLocalFiles` already returns.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:50:56 +08:00
Arvin Xu 689f8a3993 🔒 fix(bot): stop returning bot credentials from the channel list (#19452)
`lh bot list --json` printed every bot's credentials in the clear. In a
workspace that is worse than it sounds: the read path filters on
`workspace_id` alone with no `user_id` term, and the list procedure carries
no write gate, so any member — viewers included — could dump every
colleague's bot token and app secret. That is how a live Feishu app secret
ended up pasted into a chat transcript.

A list is an inventory. It now omits `credentials` entirely, and the detail
read masks each secret instead of returning it.

Masking has to survive the edit form, which reads credentials and submits
them back. The model replaces the credential blob wholesale, so persisting
the mask would destroy the secret and dropping the masked keys would delete
it just the same. `update` therefore resolves every mask back to the stored
value before anything else inspects it, and `create` rejects a mask outright
since a new binding has no secret for it to stand in for.

Which fields are secret comes from the form schema's existing `type:
'password'` marking, so Discord's `publicKey` stays readable while its
`botToken` does not. WeChat fills credentials from its QR handshake and has
no schema to read, so it names its two identifiers through the new
`publicCredentialKeys`. Anything unclassified is treated as secret.

Channel export dropped credentials too, with a line of copy saying so —
exporting masks would have produced a file that looks complete and fails on
import.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:19:17 +08:00
Arvin Xu 1eacff997e 🔒 fix(acceptance): close the holes the open discussion opened (#19448)
* 🔒 fix(acceptance): close the holes the open discussion opened

Opening an acceptance's discussion to whoever holds its public link widened
three things it should not have.

An agent belongs to its workspace, not to its readers. The author-agent gate
was scoped by the acceptance's workspace, which answers "is this agent
visible there", not "may this caller use it" — and the discussion payload
hands a visitor the ids to pick from. It is now scoped to the workspace only
for callers who actually belong to it; everyone else falls back to their own
agents.

Read-only means read-only. A `viewer` of the acceptance's own workspace was
swept in by "anyone signed in who can read it" and could write on a private
delivery, unlike the same role on topic and document comments. Commenting
now needs the public link or a non-viewer seat.

An open discussion needs a way to close an unwanted remark. Its author is
exactly who will not remove it, so the acceptance's reviewers may now take
one down — the same tombstone the author's own deletion leaves — and a
visitor gets a per-minute ceiling on how much they can land.

Two client reads of `canDelete` as "I wrote this" are replaced by the author
id, since that flag now also turns on for a moderator: whether a thread may
be closed, and whose approval the reviewer bar is showing. The read-only
line no longer flashes while the permission answer is in flight, and the
sign-in return URL moves to a shared helper.

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

* 🔒 fix(acceptance): do not call an outside reader a departed teammate

The `former` tag infers "left the workspace" from "is not a member now".
That only holds on a delivery nobody outside could write on. With the link
public, most people in the discussion never had a seat, so the tag is
dropped there rather than guessed.

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

* 🐛 fix(acceptance): charge the visitor ceiling where the row is written

Codex review of the open-discussion hardening, three findings.

The ceiling was counted before the write and enforced after it, so a burst
of parallel requests each read the same under-limit count and all landed —
which is the shape a flood actually arrives in. It now travels into the
write: the transaction takes an advisory lock on (acceptance, author),
inserts, counts, and rolls itself back when it is the one over the line.
Everybody else still writes in parallel.

The same move fixes a retry being punished for a remark it already made. A
replay of the tenth comment's idempotency key inserts nothing, so it is no
longer counted and answers with the row it wrote the first time, instead of
a refusal for a lost response.

`canResolveThread` reads the signed-in identity, which arrives after the
first paint, but the overlay memo did not depend on it — a thread could keep
the answer computed while nobody was signed in until an unrelated comment
changed. It is a `useCallback` on what it reads, and the memo depends on it.

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

* 🐛 fix(workbench): let the SSR electron stub answer the gateway device question

Five shared store modules read getElectronStoreState().gatewayDeviceInfo to
decide whether a working directory is local. They enter the workbench SSR graph
through the acceptance check route, where the stub had no such key, so the stub
surface guard failed the build. Give the stub the key with no device, which is
the truthful answer on the server.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:15:06 +08:00
Innei 2c5faed6c6 🐛 fix(theme): compose ConfigProvider outside ThemeProvider in every shell (#19457)
* 🐛 fix(theme): compose ConfigProvider outside ThemeProvider in every shell

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

* ♻️ refactor(motion): always render through LazyMotion's m, never the full motion runtime

Claude-Session: https://claude.ai/code/session_019rSFnGHUE9kq3LMCUxugzR
2026-09-12 03:47:01 +08:00
Innei 48c61be524 🔥 chore(theme): remove AntdStaticMethods now that nothing renders through antd static APIs (#19458)
Claude-Session: https://claude.ai/code/session_019rSFnGHUE9kq3LMCUxugzR
2026-09-12 02:40:29 +08:00
Innei 15c5a6fb05 🔖 chore(cli): bump version to 0.0.54 (#19451)
Ships `lh artifact publish` (#19360).

Claude-Session: https://claude.ai/code/session_019jVhraxkznG9MtkXaux657
2026-09-12 01:47:04 +08:00
Arvin Xu 85d4f3a79c 🐛 fix(bot): stop a binding outside the active scope from being undeletable (#19446)
* 🐛 fix(bot): stop a binding outside the active scope from being undeletable

`(platform, applicationId)` on `agent_bot_providers` is unique across the
whole deployment because it is the webhook routing key, but `list`, `findById`
and `delete` all filter by `buildWorkspaceWhere`. A binding that lives in the
caller's other scope — a workspace row seen from a personal session, or the
reverse — is therefore invisible to `list`, untouched by `delete`, and still
holding the application id that every re-bind collides with.

Three things then conspired to make it unexplainable. The delete never checked
how many rows it removed and the CLI printed its checkmark unconditionally, so
a no-op read as done. The create handler rewrote any 23505 into "already
registered" without looking up who held it, so the message asserted a cause
nobody had verified. Together they point at a phantom: a constraint with
seemingly nothing behind it, which reads as a server-side soft-delete leak.
The table has no soft-delete columns at all.

- `delete` returns the rows it removed; the router falls back to a scope-free
  lookup and lets the creator reclaim their own binding from either side,
  and answers NOT_FOUND instead of pretending otherwise. A binding owned by
  someone else stays NOT_FOUND so the error cannot confirm it exists.
- A conflict now resolves the real holder and names the agent and scope when
  it belongs to the caller, with the exact `lh bot remove` to run. Another
  account's binding is reported as taken without naming agent or workspace.
- `lh bot remove` fails loudly when the server removed nothing.

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

* 🔒 fix(bot): re-authorize a cross-scope reclaim against the binding's own workspace

Matching only the creator skipped the owning workspace's authorization
entirely: `withScopedPermission('agent:update')` is evaluated against the
caller's ACTIVE scope, which by definition is not the one holding a stranded
row. A user removed from workspace B could reach in from personal scope and
delete an integration B still runs.

Re-check where it counts — an active, non-viewer seat in the workspace the
binding actually lives in. A binding with no workspace stays personal, so the
creator check already settles it.

Found by Codex review on #19446.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 01:14:52 +08:00
Arvin Xu e1d9a716ad 🐛 fix(hetero): stop a sandbox spawn 504 from killing a live or finished run (#19443)
The cloud sandbox `runCommand` call is issued with `background: true`, but the
sandbox gateway can hold the connection and answer `Gateway Timeout` a minute
or more after the sandbox actually booted and started streaming events back
through `heteroIngest`. The `spawnHeteroSandbox` catch finalized on every
rejection, so that late 504 was treated as "the run never started": it blanked
the assistant message, stamped an error bubble on a turn the user had already
read, marked the op row and its task failed, and closed the UI stream out from
under a sandbox that was still producing output.

Probe two cheap reads before finalizing — the op row's status, and the topic's
`heteroCurrentMsgId` ingest pointer — and skip the finalize when either shows
the run reached the sandbox. A run that passes the probe and then dies is still
reaped by the agent-gateway inactivity watchdog.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 00:58:31 +08:00
LiJian b0eb29b1af 🐛 fix(tool-execution): sign Market calls with the run identity (#19444)
agent-builder and agent-management built DiscoverService with no options, so
they signed no trusted-client token and every server-executed market read came
back unauthorized. The model reports that as an ordinary tool failure and works
around it, so the operation still ends `done` and the user is left with an agent
that has no market tool attached.

lobe-skills signed its Market identity without workspaceId, so it acted as the
personal account while lobe-creds and lobe-cloud-sandbox acted as the workspace.
Under a workspace that split one topic across two sandbox sessions: injected
credentials landed in one and every lobe-skills command ran in the other.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 00:21:37 +08:00
Arvin Xu bc5c81f319 ✨ feat(acceptance): let anyone with the link join the discussion (#19420)
* ✨ feat(acceptance): let anyone with the link join the discussion

A public acceptance is passed around as a link so other people can answer
the evidence in it, yet only the creator and their workspace could write.
Reading and remarking are now the same right: any signed-in reader can
comment, reply, annotate a screenshot and react.

Speaking for the delivery stays narrow. Approving a round, posting a
round's own introduction, and closing someone else's thread still need the
creator or a non-viewer member of the acceptance's workspace; the
acceptance decision itself is unchanged.

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

* 💄 style(acceptance): tell a shared reader what they can actually do

The shared-link notice promised the discussion to everyone. A signed-out
reader gets no reply box, so it now reads what that viewer may do: review,
join the discussion, or only read.

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

* 💄 style(acceptance): answer the review of the shared-link discussion

Seven notes from the acceptance review of this delivery, all in what the
page shows rather than in who may write:

- the add-reaction button leads its row, so it stays put instead of sliding
  right by one chip each time somebody reacts
- an author's colour is the scale's solid step. The bare hue is the tint
  step: in light mode cyan lands near #95f3d9, and a 2px box in that colour
  on a white screenshot is a mark nobody can see
- a screenshot posted into a remark is shown at reading size, not as a 56px
  chip. "Here is what I see instead" is unreadable at 56px
- a signed-out reader gets a way in where the reply box would have been,
  rather than a grey line stating the rule
- one row closes a check: circling the evidence on the left, the verdict
  buttons on the right, instead of two half-empty rows
- an acceptance that was never shared with this reader says so, instead of
  blaming the network and offering a retry that cannot work
- the timeline rail stops at the composer instead of hanging 14px below it

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 00:01:29 +08:00
Innei bb97d05277 🐛 fix(desktop): stabilize renderer OTA compatibility and full-release state (#19410)
* 🐛 fix(desktop): stabilize renderer OTA compatibility and full-release state

* fix: allow sentry cli install build
2026-09-11 23:39:47 +08:00
Rdmclin2 1334209461 ✨ feat(openapi): expose eval resources and external run lifecycle (#19428)
* ✨ feat(openapi): expose eval resources and external run lifecycle

* 🐛 fix(openapi): report and aggregate shared eval dataset cases
2026-09-11 17:00:23 +08:00
Innei 07fb752547 🐛 fix(portal): guide outside-workspace HTML publishing (#19338)
* 🐛 fix(portal): guide outside-workspace HTML publishing

* 💄 style(portal): keep external-read consent above footer

* ✨ feat(portal): confirm external file access

* 💄 style(portal): align external-read consent typography

* 🐛 fix(portal): harden outside-workspace publishing

* 🐛 fix: secure external workspace HTML publishing

* 🐛 fix(portal): bound external publish reads and honour local base hrefs

- reject an external publish asset by `stat` size before reading it, on both
  the device-control and Electron transports, instead of letting the renderer
  find out after a multi-gigabyte base64 round trip
- keep a `<base href>` that escapes the workspace, so its refs are reported as
  real outside-workspace dependencies instead of resolving back inside
- drop a local `<base>` from the relocated copy, whose resource hrefs are
  already rewritten relative to its own directory

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

* 🔧 chore(deps): register html-artifact in the standalone app workspaces

`apps/cli` and `apps/desktop` enumerate their workspace packages instead of
globbing, so `packages/device-control`'s new `@lobechat/html-artifact`
dependency could not resolve there.

Claude-Session: https://claude.ai/code/session_017KB2isGJ1RDpSaHiv88Xto
2026-09-11 15:58:31 +08:00
Innei 2933b9fcc5 ⚡️ perf(desktop): show the main window on first paint, compile cache, dev progress (#19429)
* ⚡️ perf(desktop): show the main window on the loading screen's first paint

`ready-to-show` only fires with the `load` event for the desktop entry, which
lands ~250ms after the static loading screen has actually painted. The preload
now reports that first paint (double rAF after `#loading-screen` enters the
DOM) and the main process shows the window on whichever signal arrives first.

Measured on the packaged app (warm start, window creation = 0): window visible
at +140ms instead of +370ms, with the loading screen already painted.

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

* ⚡️ perf(desktop): enable the V8 compile cache for the main process bundle

Injected as an entry banner because rolldown hoists chunk requires above any
statement in the entry source, so `main-app` would otherwise load uncached.
Main bundle load 55ms → 37ms warm; exec → main.js ~10ms faster on a
packaged build.

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

* ✨ feat(desktop): show Vite transform progress on the dev loading screens

A cold `bun run dev` sits on the loading logo for 10s+ while Vite transforms
thousands of modules. A serve-only plugin streams the transform count and the
current file over the HMR socket, and an injected client renders it at the
bottom of the window for as long as any boot placeholder (`#loading-screen`,
`#boot-shell`, `#app-shell-fallback`) is on screen.

Claude-Session: https://claude.ai/code/session_012z4cpfrWDXZDtNgT4uJKoS
2026-09-11 15:50:00 +08:00
Rdmclin2 f2a0901ef1 🐛 fix: harden bot messaging and channel setup (#19405)
* 🐛 fix: harden bot messaging and channel setup

* 🐛 fix: retain bot messages across sender and replay races

* 🐛 fix: retry deferred bot replay independently

* 🐛 fix: preserve command turns and debounce owner lookup
2026-09-11 12:30:23 +08:00
Rdmclin2 8171469b80 ✨ feat: read Feishu documents and preserve rich-text images (#19401)
* ✨ feat: read Feishu documents and preserve rich-text images

* 🐛 fix: bound Feishu markdown image scanning

* 🌐 chore: refresh translations after rebase
2026-09-11 11:18:39 +08:00
Rdmclin2 91a77ae585 ✨ feat: support member mentions in workspace documents (#19402)
* ✨ feat: support member mentions in workspace documents

* 🌐 chore: refresh generated translations

* 🐛 fix: enforce knowledge base access for document notifications

* ✅ test: include mention plugin in readonly editor mock
2026-09-11 09:15:36 +08:00
Arvin Xu f6660c1666 🐛 fix(goal): recover pipeline failures instead of escalating them to a person (#19377)
* 🐛 fix(goal): recover pipeline failures instead of escalating them to a person

Supervision only acted on a failure that had already produced an errored
operation. Dispatch failures, a dropped device link and a crashed verifier
never take that shape — the run either never started or finished cleanly and
something around it broke — so the whole class escalated to a human decision
gate. In a live run these were the only three interruptions, all of them
recorded as `Only a confirmed failed operation can be recovered`, and each one
stopped a long-horizon goal until someone clicked retry.

- Classify a failure by where it happened. An `agent-run` failure is still
  judged by its own error text; a `pipeline` failure recovers without one,
  because retrying re-uses authority the goal already granted.
- Keep the human gates that matter: an explicit interruption, a limit stop, a
  cancelled Task, exhausted attempts, and anything reading as credentials,
  permission or spend.
- Route `VERIFICATION_ERRORED_ERROR` to the verification recovery branch. A
  verifier that could not run never judged the delivery, so it is infrastructure
  rather than a verdict; without a branch it fell through to the gate.
- Move the three Task error contract strings into `@lobechat/const/goal` so the
  side that writes one and the coordinator that matches on it cannot drift.

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

* 🐛 fix(goal): apply pipeline recovery on the status the Task actually holds

Codex review caught the previous commit widening eligibility without widening
the step that acts on it, so the fix was a no-op for its own target case.

- The recovery compare-and-set expected `failed`. A pipeline failure leaves the
  Task `paused`, so the swap never matched, the incident was marked escalated
  and the goal fell back to the human gate anyway. Compare against the status
  revalidated inside the transaction instead.
- A kickoff that threw before writing a run has no operation to key an incident
  on, and `reviewFailure` returned early on exactly that shape. Key those on the
  Task and its attempt so they are recorded, deduplicated and budgeted like any
  other incident.

Both regressions are covered by tests that fail without these changes.

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

* ♻️ refactor(goal): drop the half-built operation-less supervision path

Codex review found two consequences of keying an incident on a synthetic id:
`TaskRunnerService` leaves `totalTopics` unchanged when a retry also fails before
writing a topic, so a repeated dispatch failure reused the first key and fell
through to the gate anyway; and `recordProgress` settles an incident by locating
the failed run's sequence, which a synthetic key can never match, so the incident
stayed `retrying`, understated recovery metrics and could relabel a later
recovery as unsuccessful.

Both follow from incident identity and settlement being anchored on a real run.
Making the operation-less case correct means redesigning that, which is its own
change rather than a rider on a policy fix — and a half-built version is worse
than none, because it writes incidents that can never settle. Restore the early
return and leave that case on the human gate.

What this PR fixes is unchanged and covers every interruption observed live:
a settled run whose Task is left `paused` now recovers instead of escalating.

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

* 🐛 fix(goal): classify by the operation, not the Task status

Review found the origin test read the wrong signal. `TaskLifecycleService`
stores an ad-hoc run that fails as `paused` with an errored operation — the same
Task shape a pipeline failure leaves — so requiring `failed` labelled real agent
errors as pipeline failures and routed them around the credential and transport
checks this PR claimed to keep. An errored operation now decides it, whatever
the Task holds.

Also bound the statuses supervision may restart to the two the coordinator
actually routes to recovery. The previous commit compared the swap against
whatever status the Task held, which would have accepted a terminal one; a
completion recorded during a diagnosis must never be swapped back to `backlog`
and rerun. Guarded in the policy and again at the swap itself.

The misclassification has a regression test that fails without the fix. The
terminal-status guard is defence in depth: it is not reachable through the
coordinator, which does not route a settled Task to recovery, so its test pins
the invariant rather than reproducing a failure.

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

* 🐛 fix(goal): recognise recoverable dispatch failures instead of inferring them

Review found a fifth state that looked like a pipeline failure and was not: a
caller marking a Goal-linked Task `failed` after a clean run. Recovery would
reopen it and spend more paid work despite an explicit terminal decision.

That is a pattern, not a case. Inferring a recoverable failure from the *absence*
of a run error is an open set: every state an actor can author arrives looking
exactly like a dropped dispatch, so each round found another one. Replace it with
one allowlist read off whatever error text exists, applied to both origins.

Recoverable now means a recognised transport failure or a gateway code whose own
message states the run never started, so a retry cannot duplicate committed work:
`DEVICE_OFFLINE`, `DEVICE_CHANNEL_UNAVAILABLE`, `DEVICE_GATEWAY_UNREACHABLE`,
`DEVICE_GATEWAY_RATE_LIMITED`. The codes that say retrying will not help
(`DEVICE_GATEWAY_UNAUTHORIZED`, `GATEWAY_NOT_CONFIGURED`) or that the outcome is
unknown (`DEVICE_RESPONSE_TIMEOUT`) stay with a person, as does an authored
failure carrying an arbitrary error or none.

`failureOrigin` goes away with the inference it existed to express. The three
observed interruptions are still covered: they carried gateway codes.

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

* 🐛 fix(goal): recognise a dispatch failure in either shape it is stored as

Review found the allowlist read only one of the two forms a dispatch failure
takes in `task.error`. The coordinator's own dispatch persists the raw gateway
payload — confirmed against a live run, where the gate reason was
`{"error":"DEVICE_OFFLINE","success":false}` — while the runtime's
`finalizeHeteroDispatchError` persists the humanized headline, which contains no
code. Matching codes alone therefore still opened the gate this PR exists to
remove, on the path that finalizes through the runtime.

Match both, derived from the one headline map rather than copied text, so the
storage shape does not decide whether a person is needed.
`HETERO_DISPATCH_ERROR_HEADLINES` is exported for that.

`DEVICE_NOT_FOUND` is also called out as staying with a person: reconnecting or
rebinding the device is not something a retry does.

The regression asserts the actually persisted value through
`humanizeHeteroDispatchError`, not an injected raw code, and fails without the
headline match.

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

* 🐛 fix(goal): allow the gateway's own 5xx code into transient recovery

Review found `DEVICE_GATEWAY_ERROR` missing from the allowlist. The gateway
client emits it for any status at or above 500, and its headline states outright
that nothing started on the device and the failure is usually temporary — which
is the criterion the allowlist was written around. The fallback transport regex
only covers 502-504, so an HTTP 500 dispatch failure matched nothing and still
opened a human gate.

Regression covers both persisted forms, the raw code and the humanized headline,
and fails without the entry.

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

* 🐛 fix(goal): never restart a Task an actor already settled

Two ways a terminal decision could be overturned and paid work launched.

- `TaskService.updateStatus` replaces `error` only when a new one is supplied,
  so an actor can move a pipeline-paused Task to `failed` and leave the transport
  text that paused it, which the allowlist then reads as a dropped dispatch. The
  activity log records assignee changes rather than transitions, so the author is
  not readable; require the errored run instead. Nothing the system does writes
  `failed` with a recoverable error — the watchdog's is `Heartbeat timeout`.
- `TaskRecoveryCoordinator.recover` rejected only `running`, then swapped
  whatever status it found to `running` and spawned a run. A person completing or
  cancelling between the tick's read and the claim would have their decision
  overwritten. This path bypasses supervision entirely, so the policy guard did
  not cover it; bound the claim to the statuses that await recovery and report
  `settled` so neither call site opens a gate over somebody's own decision.

The claim race predates this branch on the lease path; routing the verifier error
here widened its reach, so both are covered by the regression.

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

* 🐛 fix(goal): claim only the paused state recovery was routed for

Review found the shared recoverable set was the wrong bound here: it includes
`failed`, so a person marking the Task failed between the tick's read and the
claim still passed the guard and had their decision swapped to `running`.

Both branches that reach this coordinator require `paused`, so that is the only
status the claim may take, and the rollback releases to the same one. Coverage
now spans a concurrent move to `completed`, `canceled` and `failed` across both
the lease and verifier-error routes.

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

* ♻️ refactor(goal): give a Task's return to work one gate

Three rounds of review each found another entrance through which automatic
recovery could overwrite a decision somebody made, because no single place owned
the question of who may put a Task back into work. Patching entrances found the
next entrance; this collects the rule instead.

`claimGoalTask` is now the only way a Goal moves a Task to `running` or
`backlog`, and it states the invariant the three sites were getting right or
wrong independently: claim against the status the decision was made on, never a
fresh re-read. Losing the compare-and-set is the correct outcome — it means the
row moved, so the decision is stale and belongs to whoever moved it.

Dispatch already worked this way, which is why it was never implicated. The
recovery coordinator re-read first and is now pinned to the routed `paused`.
Supervision had the widest window of the three: a diagnosis runs for minutes, so
the incident records the status it opened on and claims against that, rather than
only catching a change since the current tick.

Regression covers a Task moved to another recoverable status mid-diagnosis, which
the status allowlist accepts and only the claim rule refuses.

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

* 🐛 fix(goal): escalate a diagnosis that cannot say what it opened on

The compatibility fallback restored the race it was added beside: an incident
persisted before `taskStatus` existed claimed against a later read, so a person
moving the Task during such a diagnosis would have that undone.

An incident with no opening status cannot show the row is still the one it
decided about, so it escalates instead. A rolling deploy leaves at most the few
mid-diagnosis, and a human gate is the right answer for them.

Covered by a regression that strips the field the way an older release left it.

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

* 🐛 fix(goal): ask who wrote a Task's status, not what its error says

The previous guard read the error, and the error cannot tell these apart: when a
run errors with a recoverable transport failure the lifecycle leaves an errored
operation and a paused Task, and a person marking that Task `failed` without
supplying a new one keeps the same text. Requiring the errored run therefore
passed exactly the case it was meant to stop, and supervision would put an
explicit failure back to `backlog` and spend more.

An earlier commit asserted the author was not readable. That was wrong, and the
comment saying so is gone: `status` is a tracked column, so `updateWithLog`
records an actor on the transition, and `TaskService.updateStatus` documents that
its absence is how system transitions opt out of the feed. That makes actor
presence the discriminator this needed all along.

`statusAuthoredByActor` reads the latest tracked transition, requires it to be
the one that produced the current status, and both eligibility calls pass it.

Regressions cover the helper's edges and the end-to-end shape: errored run,
paused by the lifecycle, then failed by a person.

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

* 🐛 fix(goal): read the durable actor kind, and honour a pause a person made

Two gaps in the provenance check, both confirmed against the code that writes it.

- It read only the actor id columns. Those are `ON DELETE SET NULL`, and
  `taskActivityActor` writes `actorKind` into the payload for exactly that
  reason: "someone who is gone must not read as the system". An authored failure
  became recoverable once its author left the workspace. Read the kind first and
  keep the ids as the fallback for rows written before it.
- Verifier-error recovery checked only that the Task was still `paused`. A person
  moving it `paused → failed → paused` keeps the routing error, because the status
  update replaces it only when a new one is supplied, so the final pause is
  theirs and the status alone cannot say so. It now asks the same question the
  supervisor does and reports `settled`.

Covered by the tombstoned-actor shapes and by the manual round trip end to end.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:35:34 +08:00
Innei 6b608fa53e 🐛 fix(cli): declare url-join and vitest so the standalone install builds (#19408)
* 🐛 fix(cli): declare url-join and vitest so the standalone install builds

`apps/cli` installs against its own workspace, but `src/utils/agentStream.ts`
imports `url-join` and the tests import `vitest` while neither is declared
there — both resolve only through the root install's hoisting. CI does the root
install first, so this stayed invisible until someone builds the CLI on its own:

  Could not resolve 'url-join' in src/utils/agentStream.ts

`src/runtime/*` also imports `@lobechat/agent-runtime` and
`@lobechat/conversation-flow` undeclared, and those are deliberately left alone:
registering them pulls their workspace closure — 16 more packages including
`@lobechat/database` and a dozen builtin tools — into an install kept slim on
purpose. Declaring them properly is a bigger decision than this fix.

Verified by removing node_modules and running `pnpm install` + `bun run build`
inside `apps/cli` with no root install.

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

* 🔒 chore(desktop): sync lockfile with the CLI dependency declarations

apps/desktop lists ../cli as a workspace member and keeps a lockfile, so
declaring url-join and vitest in apps/cli left it outdated and failed
Test Desktop App with ERR_PNPM_OUTDATED_LOCKFILE.

Claude-Session: https://claude.ai/code/session_01558yVdNvoxocvnWAs11DGT
2026-09-11 02:49:25 +08:00
Innei b1a9979c5f ✨ feat(cli): publish a local HTML file as an artifact (#19360)
* ✨ feat(cli): publish a local HTML file as an artifact

`lh artifact publish <file>` gathers the page's local references, packs them,
and publishes the result.

The page's identity is a deployment id, recorded in `.lobehub/artifacts.json`
next to the project and committed with it, so a publish from CI updates the
same site a developer created locally. Republishing adds a revision and keeps
the public URL; `--new` makes a separate site and rebinds; `--deployment <id>`
targets one for a single run without touching the manifest. A bound id that no
longer resolves is an error — falling back to create would silently strand the
URL the caller meant to update.

Assets are read straight off the filesystem, so `--root` (default: the working
directory) sets how far up a page may reach for them. That replaces the app's
sandbox boundary, which does not apply here: the CLI reads with the user's own
permissions, and a page under `dist/` referencing `../assets/logo.png` is
ordinary rather than an escape attempt.

A create carries an idempotency key, written to the manifest *before* the
request. If the response is lost, the retry reuses the key and the server
returns the deployment it already made instead of minting a second site and
burning another quota slot. Updates are addressed by id and deliberately carry
no key: a retry costs a spare revision, never a stray site.

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

* 📝 docs(acceptance): record what the CLI artifact-publish round had to learn

Two judgment rules and two probe recipes from verifying `lh artifact publish`
against a local cloud → market → artifact-host chain.

L-E21 is the one an acceptance-checker caught mid-round: the case asserting
`--new` leaves the original site untouched published two sites with identical
bytes and a request line that echoed only the response. It read as a pass while
proving nothing, because requesting the wrong site would have printed the same
output. The rule generalises — make the two objects distinguishable before
capturing, and echo the target in the request.

L-S22 covers the quota the round consumes itself: each publish leaves an active
deployment, the Free tier caps at three, and the third run fails in a way that
reads as a regression. Compounded by clearing the cap for the wrong account —
the CLI publishes as the seeded runtime user, not the smoke's default id.

P84 records how to authenticate the CLI against lobehub-cloud's dev runtime at
all (seed an api_keys row; `dev:runtime:auth` only writes browser cookies), plus
the port-override that every runtime subcommand needs. P85 records how to
simulate a publish whose response was lost, which cannot be produced by killing
the process because the manifest is what carries the recovery state.

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

* 🐛 fix(cli): register @lobechat/html-artifact in the CLI's own workspace

`apps/cli` installs standalone against its own `pnpm-workspace.yaml`, which
enumerates the packages it may link rather than globbing them. The root install
resolved the new dependency, so this only showed up as CI's "Install CLI deps"
step failing:

  "@lobechat/html-artifact@workspace:*" is in the dependencies but no package
  named "@lobechat/html-artifact" is present in the workspace

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

* 🐛 fix(desktop): register @lobechat/html-artifact in the desktop workspace too

`apps/desktop/pnpm-workspace.yaml` lists `../cli` as a member, so the CLI's new
dependency has to be resolvable from the desktop install as well — its "Install
deps" step failed the same way the CLI's did. Unlike the CLI workspace this one
sets `lockfile: true`, so the regenerated lockfile ships with it.

Claude-Session: https://claude.ai/code/session_01558yVdNvoxocvnWAs11DGT
2026-09-11 01:27:49 +08:00
Arvin Xu d2caf84fdd ✨ feat(acceptance): serve the collaboration table, and let a round introduce itself (#19393)
* ✨ feat(acceptance): serve the collaboration table, and let a round introduce itself

The second of three layers. It stacks on the table PR and is called by
the UI PR above it; on its own it adds an endpoint nobody invokes yet.

`acceptanceComment` covers create, delete, list, resolve and an emoji
toggle. Reads are addressed by acceptance id alone and the handler
decides visibility, so a public share link works without a session;
writes need the creator or a non-viewer member of the acceptance's own
workspace. A missing right reads as NOT_FOUND rather than FORBIDDEN,
which is what the bundle read already does.

The read does the joining the page would otherwise do by hand: author
profiles, membership status, round indexes for a comment's context run,
attachment ids resolved to display URLs as the aggregate's owner, and
reaction rows folded into the comment they answer.

`lh acceptance run ingest` gains the round's own note. An agent writes
`proposal.md` beside the report; ingest posts it as a comment carrying
that round's id and signed by the agent, so a delivery introduces itself
the way a pull request does. It is the last step and never fatal — against
a server without the endpoint the round still publishes, minus the note.

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

* 🐛 fix(acceptance): scope comment agent and run/evidence references

A comment carries three references the schema alone does not constrain: the
agent it speaks as, the round it was written against, and the evidence tile it
is pinned to. Access is resolved from `acceptanceId` only, and the foreign keys
prove global existence rather than association, so any participant could name
rows from an acceptance they cannot read — or sign a comment as somebody else's
agent and expose its name, title and avatar through a public acceptance link.

Gate `authorAgentId` through `assertAgentUsableBy` in the acceptance's own
workspace, the scope the agent is being presented to, and verify that
`contextRunId` and `anchor.evidenceId` resolve back to the target acceptance
before insert.

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

* 🐛 fix(acceptance): categorize the comment namespace for API key scopes

The scope guard fails closed on any namespace missing from
`TRPC_NAMESPACE_API_KEY_RULES`, and a coverage test makes sure a newly mounted
router is categorized on purpose rather than silently denied. `acceptanceComment`
follows the acceptance it hangs off: blocked for restricted keys, reachable with
a full-access one.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:13:50 +08:00
Innei aba9e8150c 👷 build: migrate the test suite to vitest 5 (#19355)
* 👷 build: upgrade vitest to 5.0.0

Bump vitest and @vitest/coverage-v8 to 5.0.0 and migrate the configs for Vitest 4/5: drop the removed coverage.all option and replace poolOptions.forks.singleFork with maxWorkers/isolate.

* 👷 build: shim jest-dom matcher types for vitest 5

Vitest 5 exposes Assertion<R, T> and no longer reads the jest.Matchers augmentation that @testing-library/jest-dom ships, so its matchers vanish from expect(). Re-declare them on the Vitest interface, and replace the removed describe.sequential with { concurrent: false }.

* 👷 build: migrate the test suite to vitest 5

Vitest 5 (and @vitest/coverage-v8) replaces the esbuild dep optimizer with Rolldown, removed environmentMatchGlobs/coverage.all/poolOptions, forwards `new` to mock implementations, requires top-level vi.mock, and no longer resets automocks in restoreAllMocks. Migrate the root config to app/server projects with Rolldown optimizer options, shim the jest-dom matchers on Assertion<R, T>, and update the affected tests (constructor mocks, hoisted mocks, awaited assertions, explicit mock types, per-test mock defaults).

* 👷 build: make the openapi knowledge-base FileService mock constructable

tests/ was outside the per-area sweep; vitest 5 forwards `new` to the mock implementation, so the arrow implementation threw "is not a constructor".

* 👷 build: point the CI blob-report artifacts at the vitest 5 output dir

Vitest 5 writes blob reports to .vitest/blob instead of .vitest-reports, so the sharded jobs uploaded nothing and the merge jobs failed with ENOENT. Track the new directory in .gitignore too.

* 👷 build: refresh the desktop lockfile for vitest 5

apps/desktop/pnpm-lock.yaml is committed and the desktop build action installs with --frozen-lockfile, so the vitest/override change has to be reflected in it.

* 👷 build: keep the pre-vitest-4 coverage exclusions explicit

Vitest 4+ ships an empty coverageConfigDefaults.exclude, so configs that spread it silently lost the default exclusions (node_modules, dist, *.config.*) and counted more files. Inline the previous list.

* 🎨 style: format the migrated vitest configs

* 👷 build: keep canary's new class mocks constructable under vitest 5

exploreGraph/dispatchAuthorization/scopedDevices (canary #19375, #19352) mock classes with arrow implementations; vitest 5 constructs the implementation when the mock is newed, so the arrows throw 'is not a constructor'.
2026-09-10 22:18:49 +08:00
Innei 84b089450c ♻️ refactor(ui): move DraggablePanel to the antd-free base-ui build (#19365)
* ♻️ refactor(ui): move DraggablePanel to the antd-free base-ui build

@lobehub/ui@5.41.0 ships a headless DraggablePanel under
`@lobehub/ui/base-ui`, rewritten on motion values so a drag never
re-renders React.

- switch all 10 import sites to `@lobehub/ui/base-ui`
- drop `stableLayout`: the new panel holds the content size while the
  box animates, so the behaviour is unconditional
- drop ChatTerminal's `smoothResize` workaround and align its unmount
  delay with the panel's own 250ms collapse

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

* ♻️ refactor(share): move the share portal to the antd-free DraggablePanel

The original sweep only covered `src/`, so this render site in the share app
kept importing the antd component.

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

* ⬆️ chore(deps): bump @lobehub/ui to 5.42.1

5.42.1 carries the fix that makes `collapseThreshold` work again behind
`RightPanel`'s `expandable={false}`, which this migration depends on.

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

* ⬆️ chore(deps): bump @lobehub/ui to 5.42.2

Picks up the drag-collapse clipping fix (lobehub/ui#651).

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

* 🔧 chore: exclude the whole @lobehub scope from minimumReleaseAge

Every release of ours is one we cut ourselves, so the release-age window only
costs a version-pinned exclude entry on each bump.

Claude-Session: https://claude.ai/code/session_012BkEgtdLyHGbfKsHAL4Z5U
2026-09-10 21:33:53 +08:00
Arvin Xu 99fbd13ba0 🐛 fix(agent): accept a typed answer in a multi-select intervention question (#19399)
Submitting an AskUserQuestion card failed with a 500 whenever the user
wrote their own answer in a multi-select question. Two separate defects
stacked up.

The answer was genuinely rejected. The client carries a "write your own"
entry as one more string in the multi-select array, and the server marks
every AskUser question `allowCustomAnswer: true`, but the multi branch of
`hasValidAnswers` whitelists the option ids unconditionally — only the
single-select branch below it honors the flag. Any typed answer therefore
failed the whole submit, and the user had no way to tell which question
was at fault. The multi branch now reads the same flag.

The rejection then surfaced as an Internal Server Error. A contract
failure is a statement about the request, so map the intervention error
codes to their tRPC equivalents at the three resolution procedures:
invalid payloads become BAD_REQUEST, lost races and stale revisions
CONFLICT, and an unavailable review FORBIDDEN. Unrecognized failures are
passed through untouched so a genuine server fault still reads as one.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:11:08 +08:00
Arvin Xu f4a39da7d9 ✨ feat(goal): let exploration revise a flawed protocol instead of only branching (#19375)
* ✨ feat(goal): let exploration revise a flawed protocol instead of only branching

The exploration planner could only `expand` or `verify`, so when an experiment
measured the wrong thing its only move was a sibling that inherited the same
flawed instrument. It also had no way to tell which experiments a previous turn
authored, so it could not see that its last branch had made things worse.

- Add a `revise` action that re-runs one experiment with a corrected protocol
  inside the parent's container, without consuming an experiment slot.
- Bound revisions per target via `derived_from` edges so a planner cannot keep
  polishing one instrument instead of changing the question.
- Pass `derivedFromId` to the planner so it can compare a derived experiment
  against its parent before repeating that direction.

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

* 🐛 fix(goal): separate a protocol correction from an ordinary branch

Codex review found three ways the revision path and the expansion path were
conflated, because both write a `derived_from` edge.

- The revision cap counted every `derived_from` edge at the target, so an
  experiment with two ordinary branches was refused its first correction. Count
  only edges a task sourced, which is what a correction is.
- A standalone correction read back as an experiment: it spent an experiment
  slot and could then be revised as a fresh target with an empty budget, an
  unbounded chain. Exclude corrections from the experiment selector on both the
  planner and the apply side so the two see the same graph.
- A dispatched correction resolved its provenance through its container, which
  carries the container's own branch parent, so the run received the results of
  an older experiment. Prefer the node's own provenance edge.

The lookup moves to `provenanceParentId` in `@lobechat/utils/goalGraph` next to
the two new predicates, so the rule is unit-testable rather than inline in a
600-line dispatch path.

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

* 🐛 fix(goal): keep a correction's evidence and stop a spent allowance pausing the goal

Two consequences of the previous commit, both found in review.

- Excluding corrections from the experiment selector also hid their results.
  `experimentResults` only walked containment, so a standalone correction's
  findings were folded into neither itself nor the experiment it corrected, and
  the next exploration turn evaluated pre-correction evidence only. Gather an
  `experimentScope` that includes the corrections aimed at a node, and use it for
  produced Work versions too.
- A spent revision allowance threw, and `exploreGraph` treats a throw as a
  planner crash: it paused the whole goal, and resuming could reach the same
  choice again because nothing told the planner the allowance was gone. Return it
  as an outcome that re-plans, and put `revisionsRemaining` in the planner input
  so the choice is observable before it is made.

`MAX_PROTOCOL_REVISIONS` moves next to the revision predicates in
`@lobechat/utils/goalGraph`, since both the model and the planner input need it.

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

* 🐛 fix(goal): give corrections a container, a released lease and a documented contract

Four more from review, all consequences of the correction path being bolted onto
a graph shaped for branches.

- A spent allowance returned without clearing the checkpoint, so the re-plan it
  asked for was refused by the next claim until the lease timed out. Release it
  like every other terminal path.
- A correction of an uncontained seed was another root task: invisible to the
  planner, unable to inherit the previous correction's Work, and read back as a
  slot-consuming experiment by an older release. Require an experiment container
  and let those goals expand first, which removes all three at once. Both apply
  paths now gather `experimentScope`, so a correction's Work carries forward.
- `revisionsRemaining` reports zero for a node that cannot hold a correction,
  rather than inviting a choice the apply step would refuse.
- `EXPLORATION.md` documented two actions. It now covers the third, the
  slot-free guarantee, the per-experiment bound and why corrections are serial.

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

* 🐛 fix(goal): record corrections on their own edge kind

Review found `derived_from` was the wrong carrier. It is generic provenance that
predates this feature, `goal.addEdge` accepts it, `createEdge` puts no
task-source restriction on it, and an existing dispatch test authors exactly that
shape. Treating any task-sourced one as a correction therefore reinterpreted
hand-authored provenance: those tasks vanished from planner history and slot
counting while their results were attributed to another node.

Corrections now use a dedicated `revises` kind, written only by the coordinator
and deliberately absent from the public `addEdge` vocabulary, so the
discriminator is unambiguous and nothing existing changes meaning. The column is
plain text, so no migration is involved.

Also stop replanning on a rejected revision. Returning `advanced` re-invoked the
planner with the same graph and the same `revisionsRemaining: 0`, which could
spend an advance's whole tick budget repeating one question; the advance now ends
and the goal stays running for the next wakeup.

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

* 📝 docs(goal): note the dedicated revises edge in the exploration contract

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

* 🐛 fix(goal): park a goal whose planner asks past a spent revision allowance

Review found ending only the current advance left the goal running with an
unchanged graph, so every sweep could buy another identical planning call
indefinitely, and enough such goals would crowd the sweep's bounded scan. The
previous round's `advanced` had the same defect inside one advance.

Park it instead, the way an exhausted experiment budget already does:
`pausedBy: 'exploration_revision_limit'`, a recorded status transition naming the
target and the count, and `no_progress` carrying that reason. Resuming replans.
The new reason is deliberately not treated as budget-stopped, so raising a budget
does not silently reopen a goal that needs a different plan.

Also documents the rollback shape for a queued correction: the previous
coordinator resolves provenance through the containing experiment, so a rolled
back deployment dispatches it with that experiment's branch parent instead of the
one it corrects. The corrected protocol lives in the Task description and
survives, so the loss is context rather than a wrong instruction; the note says
to drain queued corrections first if that matters.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:12:47 +08:00