List with filter chips lives in the nav panel, the editor uses the shared
rich EditorCanvas, and the right column becomes an AI panel with summary,
suggestions, related items, comments and a bottom composer. Collapsed
panels can be restored by button and hotkey.
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
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>
* ✨ 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>
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>
* ✨ 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>
* ♻️ 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>
* ⚡️ 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>
* ♻️ 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>
Gate TabBar behind useDeferredMount so it stays off the first paint, mount
tabs only once the strip is measured, and jump every spring on the first
populated commit so tabs restored at boot land at their final geometry.
Read TabHost store values through useDeferredValue so activating a cold
tab no longer blocks the strip's close animation.
Claude-Session: https://claude.ai/code/session_01AMZ2cLD8cHCaSiK3Pz5LCB
* ✨ 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
Replace the "Overview" label header with a repo identity block, cut sections
with dividers on one 16px baseline, render sync as "N commits behind · Pull"
rows, CI as a tinted pill, and Skills / Documents as list rows. Projects nav
group now uses the shared skeleton while loading. BranchSwitcher keeps its
data through the close animation and caps rendering at 100 rows until the
upstream DropdownMenu gains virtualization.
Claude-Session: https://claude.ai/code/session_01Y6cR5U6xxfWWwJHzeeV5Sa
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
There are two Mecha layers doing the same job against different data sources.
The browser assembles an agent's effective run config in
`src/services/chat/mecha` (~2300 lines including its context builder); the
server assembles the same thing in `apps/server/src/modules/Mecha` plus an
851-line `serverCallLlmContextBuilder` that inlines the resolution alongside
reads of six database models. Both wrap the same shared `MessagesEngine`, and
the primitives underneath (`getActivePluginIds`, `ToolResolver`,
`resolveTopicReferences`) were extracted long ago — only the orchestration
above them was left duplicated.
Spot-checked rather than inferred from size: both sides independently compute
`enabledToolIds`, `enabledManifests` and the merged `systemRole` from an agent
config, producing the same shape from different sources.
This moves the browser's resolution rules into `@lobechat/mecha` unchanged,
behind an `AgentConfigSnapshot` of everything they previously read from
Zustand. The browser now gathers that snapshot — 76 lines — and delegates.
Nothing else changes: the barrel re-exports the same names, so no call site
moved, and `getTargetAgentId` stays behind because it reads the active agent.
The package takes the name both hosts already use for this layer, because the
rest of it follows: context engineering, tool composition and model-parameter
resolution are duplicated the same way and belong in the same home. A resolver
is the first inhabitant, not the whole tenancy.
It is a package of its own rather than part of `@lobechat/agent-runtime`
because the runtime keeps exactly three runtime dependencies — everything
heavy is type-only — and that is what let the CLI adopt it. These rules need
`builtin-agents`, which alone pulls in eight builtin-tool packages. Folding
them in would invert the property that makes the runtime portable. The runtime
also never resolves config; it reads `state.metadata.agentConfig` as an opaque
cast for a single field, so resolution belongs to the host that starts it.
`canManage` is an input rather than something the rules compute. The browser
answers it from the cache its picker populates and the server from
`isResourceAuthorOrAdmin`, which is asynchronous — a seam that only works if
the decision arrives already made.
Behaviour is pinned by the existing suite: all 77 of
`agentConfigResolver.test.ts` pass untouched, and they mock the stores, so they
exercise the new gathering and the extracted rules end to end. The package also
has its own suite that builds the snapshot by hand and never mocks a store, so
a store read creeping back in fails there even while the client tests still
pass.
The server and a device adopt this in later PRs — one host at a time, so a
regression stays attributable. For the device the payoff is the point: its
context port stops being an 851-line problem and becomes a snapshot to fill.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ♻️ 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>
* 💄 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>
* 💄 style(task): reflow the task detail header by column width
Wide columns keep a Linear-style borderless properties sidebar; narrow
columns (Portal, beside the task-agent panel) fold the same triggers into
a pill row under the title. The parent line stays single-line with an
ellipsis instead of squeezing its label one character per line.
Claude-Session: https://claude.ai/code/session_019rSFnGHUE9kq3LMCUxugzR
* 🐛 fix(task): stop the subtask progress menu closing on click after hover
The dropdown opens on hover (trigger "both"), so the manual close toggle in
the tag click handler dismissed the menu the pointer had just opened. Only
the lazy list path owns open state now.
Claude-Session: https://claude.ai/code/session_019rSFnGHUE9kq3LMCUxugzR
* 💄 style(task): let the title textarea keep its autosize height
The shared title input carried flex: 1 (basis 0), so inside the header grid
cell Chrome shrank antd's autosize height whenever the title rewrapped.
Claude-Session: https://claude.ai/code/session_019rSFnGHUE9kq3LMCUxugzR
* 🐛 fix(task): open the subtask progress menu on click only
Hover-open plus click-toggle meant the menu the pointer had just opened
closed on the click that followed.
Claude-Session: https://claude.ai/code/session_019rSFnGHUE9kq3LMCUxugzR
* 💄 style(task): give the task detail route its own skeleton
The route fell back to the generic detail surface skeleton (avatar, chips,
card), so the first frame swapped shape once the page skeleton mounted.
Claude-Session: https://claude.ai/code/session_019rSFnGHUE9kq3LMCUxugzR
* ♻️ refactor(task): fold the route chrome into TaskDetailSkeleton
One skeleton component serves the route (with NavHeader) and the page /
Portal bodies via the chrome prop, instead of a second wrapper file.
Claude-Session: https://claude.ai/code/session_019rSFnGHUE9kq3LMCUxugzR
- 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
@lobehub/ui 5.42.7 stops forcing 12px font, fixed line-height and a 16px flex gap
on ScrollArea content, which is what shrank the nav panel. Bump and remove the
per-callsite inherit/gap:0 overrides that only existed to undo that.
Claude-Session: https://claude.ai/code/session_01FYDWLFnDqY7ngaqrECg49A
* 🐛 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.
Three hosts drive this runtime and each wrote its own loop: the server's
`executeSync`, the browser's `streamingExecutor`, and the package example. A
fourth is coming for device-hosted runs. They have drifted.
The server breaks explicitly on `interrupted`, on a parked status, and on
`shouldContinueExecution` — a private method that is the only complete
statement of the rules, covering cost limits and a missing next context. The
browser loops on `status !== 'done' && status !== 'error'` and escapes a
parked run only because its steps happen to return no next context. The two
agree today by coincidence, not by design: whoever changes what a parked step
returns will make the browser take another turn on a parked state.
`runAgentLoop` owns exactly two things — when to stop, and what context the
next step receives. It takes the step as a parameter rather than calling
`runtime.step` itself, because a step means very different things per host: on
the server it claims a distributed lock, persists state, dispatches hooks and
records a trace; in the browser and on a device it is a direct `runtime.step`.
Collapsing those is how a shared loop becomes something every host works
around, so persistence, hooks, streaming and quota deliberately stay outside.
The stop reason is the part worth having. Every host re-derived it after the
fact — the server to decide whether to emit `max_steps` completion signals,
the browser to choose between a terminal lifecycle event and the parked one.
Computed once, it cannot drift.
Migrating the example surfaced a real distinction: "no next context" describes
what a step PRODUCED, so it cannot be asked before the first step. A run may
legitimately open with no context, and checking it up front would end every
loop before it began. Hence `resolveTerminalReason` (status and cost, safe
before any step) alongside the full `resolveStopReason`.
No host is migrated here. The server's per-step distributed path — where
`executeStep` is scheduled and re-entered rather than looped — is NOT a loop
and must not adopt this; only its `executeSync` should, and separately, so a
regression is attributable.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>