Commit Graph

298 Commits

Author SHA1 Message Date
Arvin Xu 230b8037f1 🐛 fix(hetero): stop settling a run whose output ingest discarded as success (#19658)
* 🐛 fix(hetero): stop settling a run whose output ingest discarded as success

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:30:05 +08:00
Arvin Xu 8b7a674d22 ✨ feat(cli): add lh doctor to diagnose the whole chain in one command (#19626)
* ✨ feat(cli): add `lh doctor` to diagnose the whole chain in one command

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ten findings from a second independent review, all confirmed:

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

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

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

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

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

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

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

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

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

Seven findings from a third independent review, all confirmed:

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:00:18 +08:00
Northword 1e5fd6270a ✨ feat(heterogeneous-agents): run pi over the RPC transport with cross-turn process reuse (#18346)
Co-authored-by: AmAzing129 <115673583+AmAzing129@users.noreply.github.com>
2026-09-16 17:28:49 +08:00
Arvin Xu 585fa593a5 🔖 chore(cli): bump version to 0.0.55 (#19618)
Ships the hetero `/goal` conversation flow (#19588), the operation-token
renewal for long hetero exec runs (#19549), the wrapped artifact share URL
(#19536) and readable task slugs (#19503).

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:36:58 +08:00
Arvin Xu 73a2f2ec65 🐛 fix(goal): make the goal agent the supervisor and fix exploration graph layout (#19578)
* 🐛 fix(goal): make the goal agent the supervisor and fix exploration graph layout

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:16:13 +08:00
Innei fc346bc4cb 🐛 fix(cli): print wrapped artifact share URL after publish (#19536)
* 🐛 fix(cli): print wrapped artifact share URL after publish

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

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

Callers import artifactShareUrl from @lobechat/utils/artifactShare.
2026-09-14 00:07:39 +08:00
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 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 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
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
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
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
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
Arvin Xu 9dfe9242ec 🐛 fix(cli): keep workspace packages inside the embedded CLI bundle (#19391)
The desktop app builds `apps/cli` from the workspace it installs at
`apps/desktop`. pnpm 12 writes those out-of-root member links with two extra
`../` segments, so `apps/cli/node_modules/@lobechat/device-control` and
`@lobechat/device-identity` dangle. Rolldown only warns, marks both imports
external and reports a successful build, so canary 2.2.17-canary.18 onwards
shipped a `lobe-cli.js` that dies at startup with
`ERR_MODULE_NOT_FOUND: @lobechat/device-control`, even for `--version`.

Repair the dangling links before bundling, and turn an unresolved import into
a build failure so a broken bundle can never ship silently again.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:21:46 +08:00
Arvin Xu d26542b423 ✨ feat(acceptance): let an author delete a flow the delivery no longer follows (#19388)
A published flow was permanent. Authoring a journey takes a few tries, and every
try stayed on the acceptance forever: an abandoned first draft still rendered as
its own root graph carrying its own never-executed checks, so the checklist filled
with rows nobody would ever verify and the reader could not tell which graph the
delivery was actually being checked against.

`lh acceptance flow delete <acceptanceId> --flow <flowId>` removes one, and only
while it has no verified history. A settled round renders from the snapshot it
froze, so deleting a graph it ran would outlive the map explaining its results;
that is refused, as is deleting a graph another flow invokes as a subflow. A
draft round has nothing to preserve, so the flow's snapshot and plan items are
stripped from it and the remaining items re-indexed, leaving the ledger as if the
flow had never been planned. The check assets the nodes pointed at are reusable
on their own and stay.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:03:13 +08:00
Coooolfan aacb88fba7 ✨ feat(heterogeneous-agent): support Devin CLI as a heterogeneous agent (#18554)
* ✨ feat(heterogeneous-agent): support Devin CLI

* 🐛 fix(heterogeneous-agent): reuse cached Devin authentication

* ✨ feat(heterogeneous-agent,cli): default Devin ACP to bypass permission mode

- Add `permissionMode` to `DevinAcpSessionOptions` and `SpawnAgentOptions`.
- Update `buildDevinAcpArgs` to place `--permission-mode` before the `acp`
  subcommand, since it is a global `devin` flag.
- Default the CLI `lh hetero exec` to start Devin with `permissionMode: 'bypass'`
  so headless connected-device runs avoid blocking on permission prompts.
- Support user-supplied `--permission-mode` in `extraArgs`, which takes
  precedence and is moved to the correct position.

* ✨ feat(heterogeneous-agent): set Devin ACP session mode via session/set_config_option

The `--permission-mode` global flag only affects the Devin CLI process;
the ACP session's mode still defaults to `accept-edits`, so shell/edit
tools still trigger `session/request_permission` and show askUserQuestion.

After `session/new` or `session/load`, call `session/set_config_option`
with `configId: 'mode'` and the resolved permission mode to actually put
the ACP session into bypass (or user-override) mode. The resolved mode is
derived from the built argv so an explicit `--permission-mode` in extra
args still wins over the default.

Add regression tests for new sessions, resumed sessions, and user override.

* ✨ feat(heterogeneous-agent): apply Devin ACP initial model via session/set_config_option

Previously `initialModel` was only used as a fallback when `session/new`
returned no model. Devin's `session/new` always reports a default model
(e.g. `swe-1-7-medium`), so a user-selected `glm-5-2` was ignored and the
default was used.

Add `applyInitialModel()` to resolve the requested model against the
`configOptions` catalog and call `session/set_config_option` with
`configId: 'model'`. It matches by raw model value and by normalized
human-readable name (so 'glm 5.2' maps to 'glm-5-2'). When only a raw
`initialModel` is provided and the catalog is unavailable (e.g. resume),
it attempts to set the value directly.

Also preserve the updated model after setting the permission mode so the
final `devin_session` event reports the selected model.

Add regression tests for model selection, label matching, default
preservation, and combined permission mode + model setup. Update the
`spawnAgent` fake to expose a 'sonnet' option for the new behavior.

* ♻️ refactor(heterogeneous-agent): stop injecting --permission-mode bypass CLI flag

Devin ACP permission mode is now applied through `session/set_config_option`
(configId: 'mode'), which is the only reliable way to change the active ACP
session mode. The global `--permission-mode` CLI flag before the `acp`
subcommand does not change the ACP session's effective mode.

- `buildDevinAcpArgs` no longer injects a default `--permission-mode`.
- It still moves a user-supplied `--permission-mode` in `extraArgs` before
  the `acp` subcommand for backwards compatibility.
- `DevinAcpSession.resolvedPermissionMode` falls back to
  `options.permissionMode` so the default CLI `bypass` is still sent via ACP.
- Updated tests to expect `acp` without the default flag and assert the
  `session/set_config_option` mode request.

* fix(devin): do not default to bypass permission mode and protect selected model from mode response

- Remove the hard-coded `permissionMode: 'bypass'` default for Devin in
  `lh hetero exec`. Users can still pass `--permission-mode` via
  `--agent-arg` when needed.
- Stop using the `session/set_config_option` mode response to overwrite the
  model resolved from `applyInitialModel`. Permission-mode negotiation can
  return config-options whose `model` value is stale/default, causing a
  freshly selected model (e.g. `glm-5-2`) to be replaced with the Devin
  default (`swe-1-7`) on `stream_start`.
- Update CLI tests to no longer expect the bypass default.

Refs LOBE-xxx (feat/devin-cli).

* fix(devin): keep default bypass permission mode while protecting selected model

Re-introduce the default `permissionMode: 'bypass'` for Devin ACP so
headless connected-device runs still do not block on permission prompts.
The previous change that removed it was too aggressive; the real bug is
that the `session/set_config_option` mode response was overwriting the
selected model. That guard in `DevinAcpSession` remains in place.

Refs LOBE-xxx (feat/devin-cli).

* ✨ feat(heterogeneous-agents): capture and emit Devin ACP usage as turn_metadata

- Forward `session/prompt` result `usage` from `DevinAcpSession` into the
  `devin_prompt_completed` synthetic event.
- Add generic ACP `Usage` parsing to `TraeAcpAdapter`, plus `usage_update`
  handling with a protected `extractUsageFromUsageUpdate` hook.
- Override `extractUsageFromUsageUpdate` in `DevinAcpAdapter` to parse
  `_meta.cognition.ai/{input,output,cachedRead,cachedWrite,thought}Tokens`.
- Emit `step_complete` with `phase: 'turn_metadata'` so the frontend reducer
  writes `usage` (and optional `cost`) onto the assistant message.
- Add `UsageData.cost` to mirror `MessageMetadata.usage`.
- Add `devinAcp.test.ts` covering prompt-result usage, `usage_update` _meta,
  and fallback paths.

* ✅ test(heterogeneous-agents): add missing request params type in spawnAgent test fixture

* 🐛 fix(heterogeneous-agent): cancel device-side CLI process on user stop

Previously only remote-task hetero types (openclaw / hermes) triggered a
`cancelHeteroTask` dispatch to the device when the user clicked Stop.
Local-cli types (claude-code / codex / devin / trae / qoder) that also
run on a remote device via `dispatchAgentRun` were silently skipped —
the UI showed cancelled but the CLI process kept running.

Two fixes:
- Server `interruptTask`: drop the `isRemoteHeterogeneousType` guard so
  any hetero type with a deviceId in runningOperation triggers the cancel
  dispatch.
- Desktop `spawnLhHeteroExec`: register the spawned child process into
  `platformTasks` via a new `onChildSpawned` callback so `cancelHeteroTask`
  can find and kill it by operationId. Also add `detached: true` so the
  process-group kill reaches the CLI without signalling the desktop app.

* 🐛 fix(cli): cancel lh connect device CLI process on user stop

The previous cancel fix only covered the desktop device path. When a
local CLI agent (devin/claude-code/codex/…) is dispatched through
`lh connect`, the spawned `lh hetero exec` child was never registered
into the task registry, so `cancelHeteroTask` dispatched from the
server returned "No task found" and the remote process kept running.

- `agentRun.ts`: spawn with `detached: true` and register the child PID
  into `taskRegistry` on spawn, keyed by operationId. Remove the entry
  on exit only if the exiting PID still owns the task.
- `taskRegistry.ts`: widen `TaskEntry.agentType` from
  `'hermes' | 'openclaw'` to `string` so local CLI agent types can be
  persisted.
- `heteroTask.ts`: `cancelHeteroTask` now signals the whole process
  group via negative-PID kill (Unix) or `taskkill /T /F` (Windows),
  with a 2s SIGINT→SIGKILL escalation — mirroring the desktop path.

* 🐛 fix(cli): add cancel-trace logging to diagnose device CLI stop path

Add [cancel-trace] logs at every hop of the cancel path:
- server interruptTask: logs when dispatching cancelHeteroTask, when
  condition is not met (with runningOp/targetOperation details), and
  the dispatch result/error
- agentRun.ts: logs when task is registered into taskRegistry, and
  when it is removed on exit (or skipped due to stale PID)
- heteroTask.ts cancelHeteroTask: logs when received, when task is
  found or not found, and each kill attempt

* 🐛 fix(server): seed deviceId/heteroType into runningOperation for local CLI hetero agents

Local CLI hetero agents (devin / claude-code / codex / …) dispatch to a
device via `dispatchAgentRun`, but `childOperation` only embedded
`deviceId`/`heteroType` for remote platform agents (openclaw / hermes)
guarded by `isRemoteHetero`. Without these fields in
`topic.metadata.runningOperation`, `interruptTask` could not find the
target operation and never dispatched `cancelHeteroTask` — so clicking
Stop in the web UI did nothing for `lh connect` devices.

Patch `runningOperation` with the resolved `dispatchDeviceId` and
`heteroType` after a successful local CLI hetero dispatch, handling
both root and child (group supervisor) operation shapes.

* 🔥 chore: remove [cancel-trace] debug logs

Clean up the temporary diagnostic logs added to trace the cancel path.
Keep only the production-level `interruptTask` and `execAgent` logs that
were already present or are useful for long-term observability.

* 🐛 fix(heterogeneous-agent): preserve cancel lifecycle state

* ♻️ refactor(heterogeneous-agent): simplify Devin ACP integration

* 🐛 fix Devin intervention review flow

* 🐛 fix heterogeneous agent cancellation

* 🐛 fix heterogeneous agent cancellation confirmation

* 🐛 fix(heterogeneous-agent): preserve Devin permission option IDs

* 🐛 fix: resolve CI failures after rebase

* 🐛 fix: route Devin verification and permissions

* 🧪 test(cli): isolate agent run cancellation registry

* 🐛 fix(cli): cancel full live agent process group
2026-09-10 16:39:30 +08:00
Arvin Xu c17b9a9388 🐛 fix(acceptance): keep flow zoom on node click and stop draft rounds from inflating (#19350)
* 🐛 fix(acceptance): keep flow zoom on node click and stop draft rounds from inflating

Clicking a node opens the step panel beside the canvas. The narrower canvas made the flow graph refit, discarding the zoom the user had just set. The viewport is now left alone, and the picked node is panned back at the same zoom only when the panel would hide it.

An unexecuted round also forked a new version on every edit: `publish` left the planned round's snapshot behind, `flow plan` inserted another round, and `ingest` chained yet another one, so a ledger could reach round 4 before a single check had run. A round now stays a draft until its first result: `publish` refreshes every draft round in place, `flow plan` reuses the open draft, and `attachRun` folds a detached harness run into it. Both round selectors label a draft "Draft" instead of giving it a number, and the acceptance skill documents the same contract.

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

* 🐛 fix(acceptance): fold only into the newest round and keep a replay pinned

Review follow-up. `attachRun` searched the whole ascending chain for a draft, so an older draft left behind by a `--from-run` replay absorbed an incoming run even after a newer round had executed. Only the newest round is the open draft now; anything behind it is an abandoned ledger position and a new round is chained instead.

A replay is also excluded from the draft paths. It is pinned to the definition it replays, so refreshing it on the next `publish` would have silently swapped in the live graph and destroyed what the replay exists to re-run. `flow plan --from-run` records `metadata.replayOfRunId`, and the shared `isDraftVerifyRun` predicate treats such a round as numbered rather than open.

Also restores the import order in the checklist inventory, which failed lint.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:55:15 +08:00
Innei b65aae96b6 👷 build: migrate workspace to pnpm 12 (#19324)
* 👷 build: migrate workspace to pnpm 12

pnpm 12 no longer reads the package.json "pnpm" field, only honours auth and registry keys from .npmrc, and requires allowBuilds instead of onlyBuiltDependencies. Move every project setting into pnpm-workspace.yaml and keep the previously effective overrides and build allowlist.

* 👷 build: resolve desktop and cli workspaces on pnpm 12

pnpm 12 no longer accepts './'-prefixed workspace package globs, so the desktop stub path is now relative without the prefix. Migrate the remaining package.json "pnpm" fields into the desktop and cli pnpm-workspace.yaml files.

* 👷 build: list protobufjs in the desktop build allowlist

pnpm 12 fails the install with ERR_PNPM_IGNORED_BUILDS when a script-bearing dependency is missing from allowBuilds. protobufjs only appears in the desktop graph on Linux, so it was absent from the previous allowlist.

* 👷 build: relink root workspace members before the desktop typecheck

pnpm 12 writes broken relative symlinks for workspace members that live outside the desktop workspace root (../../packages/*), so tsgo cannot resolve sibling packages from source. Installing the root workspace after the desktop install rewrites those links correctly.
2026-09-09 19:43:11 +08:00
Arvin Xu c209af302f 🔖 chore(cli): bump version to 0.0.53 (#19340)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 19:22:34 +08:00
Arvin Xu 83c5dbadfd ✨ feat(goal): orchestrate durable goal planning through main agent (#19277)
* ✨ feat: add opt-in goal supervision for interrupted tasks

* ✨ feat(goal): give supervisor scoped investigation and recovery tools

* 🐛 fix(goal): confirm device execution exit before releasing tasks

* ✨ feat: run Goal planning through a durable CLI main Agent

* 🐛 fix: carry Task review feedback into Goal planning

* 🐛 fix: retain main agent verification handoff in final goal task

* 🐛 fix(goal): bind main agent planning to the goal creator

* ✨ feat(goal): expose main agent execution history

* 💄 style(goal): label main agent entry as supervision progress

* 🐛 fix(goal): open supervision in the collapsed conversation panel

* 🐛 fix(goal): address operation auth and supervision review findings

* 🐛 fix(goal): show read-only supervision in the goal language

* 💄 style(goal): remove manager planning summary row

* 🚨 fix(test): drop unused userEvent import in ApiKey test

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:42:25 +08:00
Arvin Xu e580b9f85a 🐛 fix(acceptance): preserve check history when regrouping (#19306)
* 🐛 fix(acceptance): group plans by business tasks and follow flow order

* 🐛 fix(acceptance): preserve check identity and history when regrouping
2026-09-09 11:59:41 +08:00
Innei a5e74a9eb4 ⚡️ perf(spa): cut the editor runtime, ajv and database models from the boot path (#19294)
* ⚡️ perf(spa): cut the editor runtime, ajv and database models from the boot path

Four package-level import edges put ~1.5 MB raw on the first screen:

- GlobalProvider/Editor.tsx imported @lobehub/editor/react, and rolldown does
  not shake that entry down to EditorProvider, so lexical, yjs and fuse landed
  in the entry chunk. The provider is reached by path until the package
  exposes a provider-only entry.
- @lobechat/types re-exported the ajv-backed AgentGraphSchema through the
  barrel that every store touches; it now lives on the
  @lobechat/types/agent/graph subpath and the agent barrel re-exports types
  only.
- @lobechat/context-engine's root barrel dragged the database models barrel
  (drizzle) in through AgentDocumentInjector; the app reaches the
  ./agent-document subpath and the injector reads loadPolicy directly.
- packages/locales bundled locales/en-US/*.json on top of the default
  sources it mirrors; the overlay now applies only in development.

Web production build: first-screen gzip 2299 KB -> 1968 KB.

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

* 🐛 fix(vite): keep one EditorContext in dev for the path-imported provider

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

* 🐛 fix(locales): refresh consumers after the dev English overlay lands

Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH
2026-09-09 02:45:25 +08:00
Arvin Xu 9a71b5e0d4 ♻️ refactor: organize acceptance viewer and improve mobile flow review (#19290) 2026-09-08 23:09:04 +08:00
Arvin Xu e7c0a831f7 ✨ feat(goal): add set-agent and restart commands (#19181) 2026-09-08 20:36:49 +08:00
Arvin Xu 2c29ff0e44 ✨ feat: add reusable check assets and grouped acceptance flows (#19268)
* ✨ feat: add reusable acceptance user journey flows

* 📝 docs: define reusable check asset schema

* ♻️ refactor: persist acceptance checks as reusable assets

* 📝 docs: remove acceptance design markdown

* ♻️ refactor: simplify flow definitions and isolate multi-flow navigation

* ✨ feat: compose acceptance flows in a grouped canvas

* 🐛 fix: address acceptance flow review and CI failures

* 📝 docs: clarify flow completion requirements
2026-09-08 20:08:15 +08:00
Rdmclin2 8fbb091050 ✨ feat(task): record assignment, status, priority and automation changes in the activity feed (#19258)
* ✨ feat(task): record assignment changes in the task activity feed

Assigning a task to a member or an agent only rewrote a column on `tasks`,
so the detail page's activity feed had no way to show who moved it or when
— a reader saw "created the task" and nothing else, however many times the
task had changed hands.

Every write path already funnels through the `task.update` procedure (the
member/agent pickers, the `editTask` builtin, the CLI), so the diff between
the pre- and post-update assignee columns is taken there and appended to
`task_activities`. Only a slot that actually moved is written, so re-saving
the same assignee stays silent. Unlike the assignment notification beside
it, the write is awaited rather than deferred: the client refetches the
detail as soon as the mutation resolves, and a row written after the
response would miss that refetch.

`updateSchema` gains an `actorAgentId` used solely for attribution, so an
agent reassigning through its tool is recorded as the agent rather than the
human whose session it borrowed.

On the render side `ActivityAuthor` is extracted so the actor and the
assignment target share one avatar/name/tag treatment, and `AssignmentRow`
reads as "<actor> assigned the task to <target>" / "<actor> removed the
agent". A target whose account no longer resolves keeps a stub author and
renders as deleted, because collapsing it to null would turn a reassignment
into a removal.

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

* 🌐 chore(i18n): sync generated locales

Runs `bun run i18n` so the 16 generated locales pick up the task assignment
activity strings instead of falling back to English until the daily workflow
catches up.

The run also flushes locale drift that had accumulated for other namespaces
(auth / device / modelProvider / models / providers) and realigns two
hand-edited en-US / zh-CN values with their source in
`packages/locales/src/default/chat.ts`.

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

* 🔒 fix(task): make assignment attribution unforgeable and atomic

Addresses three findings from review on the assignment activity log.

**Attribution came from the request payload.** `updateSchema.actorAgentId` was
public input, so any authorized client could name an arbitrary agent and have
the durable feed claim that agent made the reassignment — while suppressing the
real user. The field is gone; the agent now rides `AuthContext.actingAgentId`,
set only by a server-side `createCaller` from an already-authorized run
context, following the contract `spendOrigin` already established.

**The diff was read outside the write.** The previous assignee was read by the
router before the update and recorded after it, so concurrent reassignments of
one task could both observe the same origin: a racing A→B and A→C persist A→B
then B→C while the log claims A→B and A→C, losing the middle state, and the
rows could be ordered differently from the writes. `updateWithAssignmentLog`
now derives the previous value from a `FOR UPDATE` read and appends the events
in the same transaction as the update.

**One writer bypassed the recorder.** `TaskRunnerService` persists the inbox
agent when an unassigned task first runs, straight through `taskModel.update`.
The assignee chip visibly changed with nothing in the feed to explain it; that
write now goes through the same path, with neither actor column set since
nobody requested it — the feed renders those as the system.

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

* 🌐 fix(task): make assignment attribution reach the router, and let locales order the sentence

Round-2 review follow-ups on the assignment activity log.

**The attribution fix was inert.** The caller built with `actingAgentId` in the
runtime factory is replaced by `ensureModels()` before any exported method
runs, and the replacement omitted it — so every agent-driven reassignment was
still recorded as the session user. The previous test missed it by building
`createTaskRuntime` directly with a ready-made caller, never exercising the
registration lifecycle; the new test drives `taskRuntime.factory` and asserts
on the caller the wrapped method actually uses.

**The row could not be translated.** Actor, verb and target were three DOM
nodes in a fixed order, so a verb-final language could not put the target
before the verb — Japanese rendered as a complete sentence with the target
stranded after it. The four verb strings are now full sentence templates with
`<actor/>` / `<target/>` placeholders rendered through `<Trans>`, so each
locale decides where the participants go. The generated locales had to be
dropped and retranslated: the keys kept their old fragment values otherwise,
since the script only fills in missing ones.

**The agent prompt claimed false provenance.** An actorless row is the runner's
system fallback, which the feed labels "System" but the prompt rendered as
"someone".

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

* 🐛 fix(task): tell absent, invisible and unnamed participants apart

Round-3 review follow-ups. All three findings come from the same mistake:
collapsing distinct identity states onto one fallback.

**An invisible agent was credited to the system.** `getAgentAvatarsByIds`
filters by `ownership()`, so another member's private agent does not resolve
for the current viewer. The actor lookup returned `undefined`, and both the row
and the prompt reserve an absent author for the system — so an agent's
reassignment was durably shown as a system action. The actor now keeps a stub
carrying `unresolved: true`, as the assignment target already did.

**A live participant could borrow a deletion label.** `users.full_name` and
`agents.title` are both nullable, so a resolved row with an empty name fell
through `||` onto "System" or "a deleted agent". Display names now fall back to
the other column each query already returns (`title || name`,
`fullName || username`), and a resolved-but-nameless participant renders as
"Unnamed" rather than as gone.

**Two formatters dropped the event.** The `viewTask` tool prompt and the CLI
task detail have their own activity branches and neither knew about
assignments, so the event vanished on those surfaces. Both render it now,
through a shared `assignmentParticipantLabel` on the prompt side; the CLI keeps
a three-line local copy rather than take a dependency on `@lobechat/prompts`.

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

* ⚡️ perf(task): show the assignment activity row in the same beat as the chip

Picking a new assignee flipped the chip instantly (that dispatch was already
optimistic) but the activity row explaining it only appeared after two
serial round-trips: the mutation, then the full task-detail refetch — a
fifteen-step aggregation that alone runs one to three seconds against a
remote database. The chip said "done" while the feed stayed silent.

The row is now synthesized in the same optimistic dispatch. The picker hands
over the chosen item's display metadata, since it is the one place that
already holds it — the store deliberately does not resolve members itself,
that source being a business-layer hook. The actor is the signed-in user;
with no known identity no row is invented.

Only a slot that actually changes produces a row, mirroring the server's own
rule, and the refetch that follows the mutation replaces the whole activity
array, which retires the synthetic rows with no reconciliation. A failed
mutation is rolled back by that same refetch. Comments share the identical
two-round-trip lag and are left as they were.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ⚡️ perf(task): show a sent comment in the feed before the round-trip returns

Comments had the same two-round-trip lag the assignment row just lost: the
post, then the full task-detail refetch, before anything appeared. Sending
now synthesizes the row in the same dispatch, authored as the signed-in user
and carrying the topic it replies to, so it lands in the feed the moment the
button is pressed.

Attachments are deliberately left to the refetch — their metadata only exists
once the server has resolved the file ids. A comment posted on an agent's
behalf through the client-first runtime is also left to the refetch, since
the store has no agent identity to attribute it to.

While the synthesized row is on screen its edit / delete menu is withheld —
the server has never seen that id, so either action would only surface an
error. A failed post is rolled back by the same server-truth refetch the
assignment path uses, and the failure still reaches the caller.

The two builders now share one module and one id prefix, so a renderer has a
single predicate for "not yet persisted".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ✨ feat(task): log status, priority and automation changes made by a person

The activity feed now explains every property a person can change from the
task's property bar, not only the assignee. A status pick, a priority pick and
turning automation on or off each leave a row: "<actor> changed the status to
<tag>", "set the priority to <tag>", "set automation to <summary>".

Only person-made changes are recorded — those made from the pickers or by an
agent acting through its tool. The runner, lifecycle and watchdog transitions
(backlog → running → completed) are deliberately not: the run row already
carries them, and a duplicate line per run would be noise. The policy is not a
flag; it is which writer a caller uses. Person-made edits go through the new
`TaskModel.updateWithLog`, the system transitions keep the plain writers.

`updateWithLog` replaces the per-property methods with one transactional
diff over a tracked column set (assignees, status, priority and the four
automation columns), so adding a tracked field is one list entry. The four
automation columns fold into a single event carrying before / after
snapshots: switching a schedule on rewrites mode, pattern and timezone in one
save, and that is one decision, not three.

All three task-detail formatters (page prompt, `viewTask` tool prompt, CLI)
render the new event, through a shared `formatPropertyValue` on the prompt
side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ✨ feat(task): fold a burst of edits to one property into a single feed entry

Fiddling with a property — priority High → Low → High inside a minute —
produced one line per hop, where an issue tracker shows "moved from A to C"
and shows nothing when the value ended up back where it started.

Collapsing happens at read time, in the detail assembly, so the rows in
`task_activities` stay append-only: the hop-by-hop history is still there for
audit or a "show all" view, and every consumer of the detail (page, `viewTask`
prompt, CLI) gets the same folded view. A run is one property by one person
with no gap over the window; it keeps the newest row's time (when it settled),
the first row's starting value and the last row's ending value. Two people
never merge — A moving it and B moving it back are two decisions, not a
cancelled one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): say where a property moved from, not only where it landed

"changed the status to In Review" hides the half of the story a reader
actually wants once a burst of edits has been folded into one entry: an
entry can now span several hops, and the starting value is what says how far
the property moved. Status, priority and automation rows read "from A to B";
automation keeps its one-sided forms for turning on ("set automation to")
and off, since there is no other side to name.

The two reworded keys had to be deleted from the generated locales and
retranslated — the script only fills in missing keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): surface every person-made property change in the feed at once

Three findings from driving the real UI:

**Automation changes never reached the feed.** `setAutomationMode` deliberately
does no post-save refetch — an async SWR write could land after the user's
next toggle and clobber it — so the row the server appended only showed up on
the next unrelated refresh. The row now rides the same optimistic patch as the
automation fields, inside the same engine transaction, so a failed save rolls
it back with them.

**Status and priority rows arrived a round-trip late.** Only assignment and
comment rows were synthesized; the other two waited for the detail refetch,
which against a remote database is the same lag the assignee row was fixed
for. Both now ride the optimistic dispatch that flips their chip.

**Priority read as "from ··· to !".** The priority tag alone is an icon; in a
sentence it is noise. The level's name now rides along.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): attribute client-runtime edits, tighten activity collapse and rollback

- update/updateStatus accept an ownership-checked actorAgentId for the
  client-first runtime; the server-side actingAgentId still wins
- collapse only extends a run when each hop starts where the last ended
- optimistic property rows fold client-side; schedule edits get a row
- addComment drops its synthesized row locally when the send fails
- payload.actorKind keeps a deleted actor from reading as the system
- task detail reads a bounded window of recent activity rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): log cascades and rolled-back starts, quieter activity rows

- collapse: any edit by someone else ends other actors' open runs
- updateStatusCascade logs one status row per family member the
  confirming person cascaded; the router passes the actor
- a person-made start rolled back after the heartbeat tick fails logs
  the undo too, so no orphan row survives the restore
- activity rows read like an issue tracker's: 16px mark, plain names,
  values as words instead of chips, agent chip dropped

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): lock cascade origins, batch their rows, widen the schedule value

- cascade reads each task's leaving status under the row lock and writes
  the family's activity rows in one INSERT
- the automation value includes the execution cap and its labels show
  timezone and cap, so a cap-only or timezone-only edit is visible
- a failed status transition drops its synthesized row locally before
  the best-effort refetch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): lay the activity feed out as a timeline

Compact rows lead with a type mark on a shared rail instead of the
actor's avatar, read as one sentence in one tone, and sit tight together;
cards still break the rail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): faces for people rows, property icons for property rows

The rail mark is the actor's avatar on created / assignment lines and the
property's icon (three-bar priority, status dot, timer) on change lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): priority rows use the app's solid priority bars

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): centre the activity rail under the cards' avatar column

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:53:00 +08:00
Arvin Xu 10375791fd 🐛 fix: make Acceptance file evidence identifiable and require descriptions in prompts (#19276)
* 🐛 fix: preserve acceptance evidence descriptions and file labels

* 📝 docs: add required descriptions to acceptance ingest examples
2026-09-08 17:23:56 +08:00
Arvin Xu c04405343a ✨ feat(goal): add nested experiment answers and exploration navigation (#19261)
* ✨ feat(goal): add nested experiment answers and exploration navigation

* 📝 docs(goal): correct exploration budget command

* 🐛 fix(cli): isolate dispatched agent run identity

* 🐛 fix: register CLI notebook reports as operation deliverables

* 🐛 fix: avoid signing notebook origins as file URLs

* 🐛 fix(cli): retain bounded event backlog across temporary outages
2026-09-08 17:09:07 +08:00
Innei a20880a125 🐛 fix(file): reserve storage before upload (#19119)
* 🐛 fix(file): reserve storage before upload

* 🐛 fix(file): gate storage quota on upload paths that skip reservations

The reservation flow only covers pre-signed tRPC uploads. Three surfaces write
`files` rows without ever consulting the quota: the OpenAPI upload endpoint,
server-side image generation, and bot-channel attachment ingest.

- OpenAPI upload checks the quota once per path that actually adds bytes, and
  maps the rejection to a 403 instead of a generic 500. Re-requesting a record
  the caller already owns adds nothing, so it stays exempt.
- Image generation admits at submission rather than at persist — blocking after
  the model call is already paid for is worse than not blocking.
- Attachment ingest charges the post-compression buffer, and a quota skip now
  says so instead of leaving the sender to guess.

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

* 🐛 fix(file): release reservations on every dataset import failure

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

* 🐛 fix(file): stop reservations from deleting pre-existing objects

Reject a reservation whose pathname already holds an object, so aborting one
can only ever delete a key it created. Serialize server-owned uploads with the
owner-row lock by threading the transaction through the quota check and the
file row it authorizes.

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

* 🐛 fix(file): keep the S3 write out of the quota transaction

Holding the owner-row lock across the upload blocked every other write to that
user for the length of the transfer, and Postgres kills the transaction outright
wherever DATABASE_STATEMENT_TIMEOUT is set, orphaning the object it had already
written. Check the quota once before spending the transfer, upload outside any
transaction, then re-check under the lock the file row is written with.

Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
2026-09-06 13:41:18 +08:00
Arvin Xu 85ac782f8a 🐛 fix(cli): apply --agent and other fields alongside --status in task edit (#19183)
* 🐛 fix(cli): apply --agent and other fields alongside --status in task edit

`lh task edit --status` early-returned through the lifecycle updateStatus API, silently dropping --agent, --name, --priority and every other field passed in the same invocation. Apply the status change and the field updates together; status-only and field-only calls keep their existing single-API paths.

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

* 🐛 fix(cli): make combined task edits atomic

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-06 13:02:23 +08:00
Arvin Xu 142557b689 ✅ test(cli): isolate LOBEHUB_JWT in workspace scope suite (#19192)
Follow-up to #19182's review: with LOBEHUB_JWT exported in the invoking shell, the persisted-workspace cases took the dispatched-run branch and failed, and the JWT test's finally deleted the caller's value. Capture/clear/restore it in beforeEach/afterEach like LOBEHUB_WORKSPACE_ID.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-06 12:36:11 +08:00
Arvin Xu 3a6e1c7a6a 🐛 fix(cli): keep persisted workspace scope out of server-dispatched runs (#19182)
A goal/task dispatch spawns `hetero exec` with an operation-scoped LOBEHUB_JWT and states its scope solely through LOBEHUB_WORKSPACE_ID. The CLI still fell through to the `lh workspace use` persisted scope, stamping every heteroIngest/heteroFinish call with the terminal user's workspace — a personal-scope topic then rejects the entire event stream with "Topic is outside the caller scope", the run's assistant message stays an empty placeholder, and the goal coordinator reclaims the (healthy) operation as abandoned.

Ignore the persisted scope whenever LOBEHUB_JWT is present; env LOBEHUB_WORKSPACE_ID still wins for workspace-scoped dispatches.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 20:04:25 +08:00
Arvin Xu ff9bcb66ec ✨ feat(cli): local message store and write-behind sync for device-hosted runs (#18908)
* ✨ feat(cli): local message store and write-behind sync for device-hosted runs

Groundwork for running the agent loop on the device instead of the server.
Measurement on production (#18899) put a device tool call at roughly 550ms of
transport plus 200ms of server-side work per step, so the loop is worth
moving — but only if moving it does not reintroduce the latency elsewhere.

The trap is on the read side. After every tool batch the runtime re-reads the
entire message list (`executors/tool.ts` calls `messages.query()` twice per
batch) and preflights the parent on every `call_llm`. On the server those are
local database queries. A device transport that answered them from the cloud
would trade the dispatch latency it just saved for read latency instead.

So `LocalMessageStore` is the authority for the duration of a run, and
replication is asynchronous. `CliMessageTransport` mints message ids locally,
which is what lets a create return without waiting: the id the executor
anchors follow-up writes to is known immediately, and the replica is
eventually written under that same id, so a replay rewrites rather than
duplicates.

Query semantics deliberately mirror `MessageModel.query`, including the part
most easily got wrong: an ABSENT scope filter means `IS NULL`, not "any", so a
read without a `threadId` returns mainline messages only. Flattening reuses
the same `@lobechat/conversation-flow` parser the server adapter uses, so
grouped and tool rows collapse identically.

`MessageWriteQueue` batches to the cloud behind the run, respecting the
200-operation cap on `message.batchMutate`. Durability is a log rather than a
blocking write: operations are appended before being considered accepted and
dropped only once confirmed, so a killed process leaves exactly the
un-replicated operations on disk for `recover()` to replay. Confirmation trims
the confirmed prefix rather than truncating, because entries appended while a
batch was in flight must survive.

Verified against the REAL runtime executors rather than a mock transport:
`callTool` / `callToolsBatch` drive this store end to end with no database.
That is what caught two things a hand-written test would have missed — the
store faithfully reproducing the server's `agentId` scoping, and the fact that
the runtime only re-reads when a result was actually persisted, which had left
the query path unexercised.

Not yet wired into a command: the transport is one port of the runtime host,
and the LLM, tool and stream ports follow. Two mutations have no cloud
equivalent yet (`deleteMessage` and tool intervention are absent from the
batch union); local state is correct and the divergence is documented at each
call site rather than silently dropped.

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

* 🐛 fix(cli): correct local query scoping and make replication crash-safe

Five issues from review, all verified against the canonical model before
changing anything.

Query scoping diverged from `MessageModel.query` in three ways, each of which
silently hands a device-hosted run a different conversation than the server
would:

- A concrete topic IS the conversation boundary and may hold messages from
  several agents — `callAgent` replies among them. The canonical query only
  applies agent scope to an inbox read, which has no topic; applying it
  universally deleted delegated-agent turns from the model's context.
- A thread read must return the thread's parent messages alongside its own
  rows, or the model starts mid-conversation. The server derives them from the
  `threads` table; a device has none, so the host now registers them and the
  store falls back to exact matching — the server's own fallback when a thread
  has no source message.
- A plain topic read excludes group messages (`matchGroup(undefined)` is
  `isNull`). That constraint was missing entirely.

The three branches are not variations on one predicate, and collapsing them is
how this went wrong; they are now written out separately, mirroring the model.

`update` replaced the whole metadata object. A `call_llm` step stamps
provenance up front and finalizes with a second, partial metadata write, so a
shallow spread dropped fields the cloud replica still had. Now deep-merged
through the same `@lobechat/utils` helper the model uses.

Replication had a crash window and a durability gap:

- `enqueue` returned after merely starting the log append, leaving an
  operation that was neither sent nor recoverable if the process exited in
  that window. It now awaits the append. That is not the property the design
  protects: a local append costs microseconds, the network round trip it
  avoids costs hundreds of milliseconds. An unwritable log is surfaced and
  marks the queue degraded rather than killing a working run.
- A process that died after the sink committed but before the acknowledgement
  was recorded would replay a byte-identical batch into a plain-insert
  backend, fail permanently, exhaust its retries and end ALL later
  replication. The sink contract now requires idempotency and offers
  `isAlreadyApplied` so a backend that cannot upsert can acknowledge a
  duplicate instead.

The integration fixture that previously gave its assistant message an
`agentId` has that removed: it was compensating for the scoping bug rather
than exercising the fix.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:34:20 +08:00
Arvin Xu 348c32d5bc ♻️ refactor(goal): rename graph-node Work wording to Task (#19081)
* ♻️ refactor(goal): rename graph-node Work wording to Task

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

* ♻️ refactor(goal): drop pre-release Work compatibility

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-03 15:56:58 +08:00
Arvin Xu 6430e476bd ✨ feat(cli): add lh workspace commands and a persisted workspace scope (#19015)
* ✨ feat(cli): add `lh workspace` commands and a persisted workspace scope

The CLI could only reach workspace content through the `LOBEHUB_WORKSPACE_ID`
env var, so `lh agent view <workspace agent>` reported "Agent not found" with
no way to discover the workspace id or switch into it.

- `lh workspace list|view|current|use|create|update|settings|stats|usage|
  members|invite|invitations|audit-log`
- `lh workspace use <id|slug>` persists the scope in `~/.lobehub/active-workspace`,
  slotting below `LOBEHUB_WORKSPACE_ID` so a one-off run can still override it
- `lh whoami` reports where the active scope came from
- typed workspace stubs in business-server so the OSS contract type-checks;
  cloud keeps supplying the real implementations

No delete/remove commands are exposed.

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

* 🐛 fix(cli): make the workspace scope report match the scope commands use

Codex review follow-up.

- `workspace use --personal` warned nothing while `LOBEHUB_WORKSPACE_ID` was
  set, so it printed "Scope set to personal" while every later mutation still
  hit that workspace; both `use` paths now share one override warning
- `workspace list` starred the persisted workspace instead of the effective
  one, marking the wrong row whenever the env var pointed elsewhere
- `workspace current` reported every failure as lost access, hiding auth and
  network errors; it now surfaces the real reason, and flags an id the server
  answers with `null` instead of reporting a typo'd scope as healthy

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

* 🐛 fix(cli): bind the persisted workspace scope, and align the OSS stub contract

Codex review follow-up.

Scope no longer survives an identity it does not belong to. `active-workspace`
now stores the account fingerprint and server URL the scope was chosen under;
a mismatch is reported as stale and drops to personal instead of attaching an
`X-Workspace-Id` the current identity has no membership in — which cloud
answers by silently downgrading to personal, so writes were landing on personal
data while every status line still said "workspace". `logout` clears it.

Stub contract now mirrors the cloud router it stands in for:

- slug gets the same length and format rules (new `@lobechat/business-const`
  workspaceSlug, minus the cloud-only route reservations)
- `getStatistics` is admin-gated, `getSettings` / `getMyStatistics` require the
  workspace header, and the member/audit procedures use the compat + role gate
  cloud uses rather than a stricter one

Also:

- `workspace invitations` counted expiry through `timeAgo`, printing
  "-604800s ago"; added `timeUntil`
- `workspace stats` is admin-only, so a member hit a bare FORBIDDEN — it now
  points at `--mine`, and lets unrelated failures through
- `workspace create --use` was the one scope-setting path missing the
  `LOBEHUB_WORKSPACE_ID` override warning
- documented the command group, the scope precedence and `active-workspace` in
  the cli skill

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

* 🐛 fix(cli): derive the scope identity from public claims, not the API key

CodeQL flagged `js/insufficient-password-hash` (high): the API-key branch
digested the key with a single unsalted SHA-256 round and wrote 64 bits of it
to `~/.lobehub/active-workspace`. The digest bought nothing — an API key
carries no readable subject, so there is no account to bind to either way.

Identity now comes only from the `sub` claim of a JWT, and API-key auth returns
no identity at all: `workspace use` refuses to save under it and points at
LOBEHUB_WORKSPACE_ID.

Resolution order also had to mirror `getAuthAndServer`, which prefers an API
key over the stored login. Reading past the key to the stored credentials bound
the scope to one account while the request authenticated as another — the exact
mismatch this binding exists to catch.

Each stale reason now carries its own message rather than one guess.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:26:08 +08:00
Arvin Xu a5f81ba846 🐛 fix(cli): make lh goal run survive transient failures, idle polls and reversed graph edges (#19020)
* 🐛 fix(cli): state `goal graph` edge relations from the row's own side

Every edge reads `source <kind> target`, but the node table listed each
row's INCOMING edges as `<kind>:<source>` — which asserts the exact
opposite of the graph. An incoming `depends_on` means the other node
depends on THIS one, so a correctly ordered plan rendered as a reversed
one and the dependency chain read backwards.

Render the inverse verb instead, so every entry is a true sentence about
the row it sits on (`part of`, `blocks`, `produced by`, …), and rename
the column to RELATIONS. The map is typed `Record<GoalEdgeKind, string>`
so a future edge kind fails type-check rather than silently printing
`undefined`, the same trap `nodeIcon` already documents.

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

* 🐛 fix(cli): keep `goal run` alive across transient failures and idle polls

`lh goal run` is meant to be left unattended for hours, but three things
made it stop early and forced an external supervisor script around it.

- A single transport failure (`fetch failed`, ECONNRESET, a proxy 502)
  rejected the tick and ended the whole loop, stranding the goal
  mid-flight. Ticks now retry within a wall-clock `--retry-window-ms`
  (default 10min; exponential backoff capped at 30s and clamped to the
  remaining window, so the command never sleeps past its own budget).
  tRPC codes that are a verdict about the request itself still abort
  immediately — retrying those only reproduces the same answer. The
  absence of a code is what marks a transport failure, so an
  unrecognised error shape errs towards surviving the blip.

- Idle `waiting_external` polls consumed `--max-ticks`, so a Work that
  legitimately ran for an hour burned the whole allowance on no-ops and
  stopped a healthy goal half-way. Only ticks that change something
  count now, reusing the same `isSameWaitingState` predicate that
  already de-duplicates the output. A stuck Work stays bounded
  server-side by the operation lease, maxRounds and maxTotalCost.

- Exhausting the budget exited 0, so a wrapper could not tell a finished
  goal from an unfinished one. It now sets a non-zero exit code and
  prints the resume command. `waiting_human` stays a terminal, zero-exit
  outcome — a decision gate is a normal stop, not a failure.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:48:26 +08:00
Neko 675e98554e 🐛 fix(cli): wait for daemon startup readiness (#19000)
* 🐛 fix(cli): report daemon startup failures

* ♻️ refactor(cli): use tinyexec for daemon processes

* 🐛 fix(cli): complete daemon startup preflight
2026-09-02 02:42:04 +08:00
Arvin Xu 14abb41438 🔖 chore(cli): bump version to 0.0.52 (#19009) 2026-09-02 01:06:59 +08:00
Innei 295c7fe0af 🐛 fix(upload): support memory-safe large file uploads (#18945)
* 🐛 fix(upload): support memory-safe large file uploads

* 🐛 fix(chunk): surface oversized parse tasks

* 🐛 fix(ci): type byte stream test controllers

* 🐛 fix(ci): sort acceptance imports and fields

* 🐛 fix(upload): cap uploads at database size range

* 🐛 fix(upload): keep size input backward compatible

* 🐛 fix(files): tolerate empty search adapter result
2026-09-01 20:54:44 +08:00
Arvin Xu a0dfd6d1da ✨ feat(cli): support role/date filters and topic info in message list (#18980)
* ✨ feat(cli): support role/date filters and topic info in message list

Extend message.listAll with the shared analytics filters (role, agentId,
topicId, startDate/endDate/range) by reusing analyticsConditions in
MessageModel.queryAll, so lh message list can pull e.g. all user
messages of the past day server-side instead of filtering a single page
client-side. The CLI gains --role/--start/--end, pushes --user down as
role=user, routes topic/agent filters through listAll, and prints a
TOPIC/THREAD column.

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

* ✨ feat(cli): show agent name for messages and default list page to 50

queryAll now left-joins agents to expose agentName/agentTitle on each
row, so listing user+assistant messages together reads as a full
conversation record with the responding agent identified. The CLI table
gains an AGENT column and the default page size moves from 30 to 50.

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

* 🐛 fix(database): stop adding a day to precise ISO end timestamps

genEndDateWhere unconditionally pushed the upper bound one day forward,
so an explicit --end 2026-09-01T12:00:00Z matched messages through Sep 2
noon. Only date-only (YYYY-MM-DD) values now get the through-end-of-day
extension; precise timestamps are used as the exact bound.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:19:26 +08:00
Arvin Xu 66c0a001be ✨ feat(goal): parallel task scheduling, trace fixes and CLI 0.0.51 (#18944)
* 🐛 fix(goal): point a running goal's trace key at the object that exists

`writeObservationRow` wrote the finalized S3 key from the first advance, but
while a goal runs only the partial exists. So `hasTrace` read `yes` for every
in-flight goal, `getGoalTrajectoryUrl` signed a URL that 404s, and a goal could
not be inspected from the server until it was over — the opposite of the
"an unfinished long-horizon goal is the normal thing to inspect" the recorder
was written around.

Found by running it: the first real production goal listed with `TRACE yes`
and then failed to download. Unit tests could not have caught it — they drive
an in-memory store where both keys are the same map, and the bug lives in the
gap between what the row claims and what object storage holds.

The key now follows the object — partial while running, finalized once the
goal is terminal, which is exactly when `finalizeGoalTrace` removes the
partial.

The client cache had the mirror-image bug: caching an in-flight trajectory
pinned the reader to whatever the goal looked like the first time it was
inspected. Only finished goals are cached now, and an older cached partial is
re-fetched rather than served.

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

* ✨ feat(goal): run independent tasks in parallel, under a concurrency cap

Four bug fixes that share no code queued behind one another for an hour. Not
because of dependencies — the trace recorded the other three as candidates with
`blockedBy: []`, so the coordinator knew they were independent and started them
anyway one at a time.

Three things made the frontier single-threaded, and all three had to go:

- `selectFrontier` returned one node. A running node stays eligible, so it was
  re-picked every tick.
- that re-pick reported `waiting_external`, which ends the advance before
  anything behind it is considered.
- dispatching also reported `waiting_external`, so even a fixed frontier would
  have started exactly one task per advance.

The decision now walks the whole ranked frontier, skips what is already in
flight or parked on a person, and takes the first candidate it can act on;
dispatching reports `advanced` so the advance keeps filling slots.
`maxConcurrentTasks` caps the fan-out — defaulting to 3, because a goal that
dispatched everything at once would spend its budget before the first result
came back.

A running task is still examined for staleness rather than skipped outright.
Skipping it made abandoned operations unreclaimable: the reclaim only ever runs
from the branch that looks at a running task, and an integration test caught
that the first version had quietly removed it.

The recorded decision input grew with the decision. The scheduler reads every
candidate's task state, so a trace that kept only the chosen one would no
longer let a replay reproduce the choice — `candidateTasks` and the cap in
force are recorded instead, and the replay adapter rebuilds the map.

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

* 🐛 fix(cli): link `lh goal create` at the goal it just created

`goal.create` returns the whole graph snapshot, so the id lives on its `goal`.
Reading `result.data.id` printed `app.lobehub.com/goal/undefined` — a link
nobody could follow — right under a graph that `printGraph` had just rendered
from `graph.goal.id` on the next line.

Same blind spot as #18912: the CLI cannot resolve the TRPC router's types, so
`result.data` widens to `any` and reading a field that isn't there compiles.
`apps/cli` is also outside the root `tsconfig.json` include and outside the CI
test matrix, so neither the type checker nor CI would have caught it.

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

* 🔖 chore(cli): bump version to 0.0.51

0.0.50 was published on 08-30, and seven changes have landed on `apps/cli` and
`@lobechat/agent-tracing` since — including `lh trace goal`, which is not in
the published package at all, and the glyph fix for `lh goal show`, which is
only visible to someone running `lh`.

The man page is regenerated with the bump: it picks up the version string and
Factory Droid joining the heterogeneous agent list. `lh trace goal` adds no
entry because the page lists top-level commands and `trace` was already there.

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

* 🐛 fix(goal): close the four gaps Codex found in the parallel scheduler

**The budget stopped guarding paid work (P1).** `needsBudget` was asked of the
frontier's head, which was safe while the head was the only thing that could be
dispatched. The scheduler now walks past a running head, so a head reporting
"no budget needed" meant the budget was never read — and `budget_exhausted` can
only fire on a budget that was read. A goal could start work past its
`maxRounds` / `maxTotalCost`. It is asked of every unblocked candidate now, and
counts a retryable failure as spending, because it is.

**The cap was advisory, not enforced (P1).** Two overlapping advances could each
read the same `inFlight` below the limit and each claim a different ready task;
the per-task CAS only prevents dispatching the *same* task twice. The count and
the claim are taken together under a per-goal advisory lock, with the in-flight
count read from the database rather than from a snapshot — the planner's check
is a fast path, this is the enforcement.

**Legacy trajectories replayed as `missing_task`.** Ticks recorded before this
change carry `frontierTask`, not `candidateTasks`. Dropping the field made every
one of those replay with no task state, reporting divergences that never
happened. It stays as a deprecated field the replay adapter falls back to.

**The cap could not actually be set.** `GoalConfig` advertised
`maxConcurrentTasks` while the `goal.create` Zod schema still allowed only
`config.recovery`, so Zod stripped it and every API-created goal silently used
the default. The schema accepts it with bounds mirroring
`resolveMaxConcurrentTasks`, and `lh goal create --max-concurrent-tasks` exposes
it — a cap reachable only by hand-written TRPC is not a cap anyone can use.

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

* 🐛 fix(goal): keep parallel scheduling moving

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:20:59 +08:00
Arvin Xu d8722c7a84 ✨ feat(goal): plan the exploration structure before executing (#18942)
* 📌 fix(deps): unpin @ant-design/icons now that 6.3.4 is fixed

Upstream shipped @ant-design/icons@6.3.4 with a matching
@ant-design/icons-svg@4.6.0, so the MetaFilled resolve failure that forced
the 6.3.2 pin (#18923) is gone. Drop both override entries and float the
direct dependency from ^6.3.4 — starting the range at the fixed release so
a stale registry mirror can never resolve the broken 6.3.3.

Verified locally: fresh install resolves 6.3.4 + icons-svg 4.6.0,
es/asn/MetaFilled exists, and `bun run build:spa:raw` exits 0.

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

* 💄 style(goal): size the inline exploration map to its content

- The inline canvas was a fixed 560px frame, so a two-node graph rendered as
  a small cluster adrift in empty margin (fitView shrinks nodes to fill the
  frame). Size the frame from the layout bounds instead; 560px stays the
  ceiling for large graphs.
- Move each card's state chip (and the @ human marker) from the title row's
  trailing edge to the card's bottom-left: a task folds them into its metric
  strip, other kinds get a light footer, and the title keeps its full width.

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

* ✨ feat(goal): plan the exploration structure before executing

Creating a goal used to copy the raw ask verbatim into exactly one problem
node + one task and dispatch it immediately: the problem node's description
was the full requirement (acceptance boilerplate included), the single task
mirrored the whole request, and its acceptance objective concatenated the
entire goal contract again. No decomposition ever happened.

- New `plan_decomposition` coordinator branch: a goal with no work is a
  planning cue, not a dead end. On first advance an LLM plans the core
  problem statement plus 1–5 independent work directions (chainGoalDecompose,
  traced as goal_decompose); planner failure degrades to a single work seeded
  from the requirement so the goal never stalls.
- Callers stop seeding the mirror task; they pass `problemDescription` (the
  user's own words) for the problem node instead of the requirement blob.
- A Work's acceptance objective no longer embeds the full goal requirement —
  each direction is judged on its own outcome; the complete contract stays
  with the terminal acceptance task.
- `GoalGraphModel.updateNodeDescription` lets the planner replace the seeded
  description with its own problem statement.

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

* 💄 style(goal): use the task family's running color on graph node chips

The running chip was a lone colorInfo blue while every other running signal
(frontier glyph, task rows) draws from TASK_STATUS_VISUALS — the odd color
read as a different state. Reference the shared visual instead.

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

* 💄 style(goal): icon-only verifier metric; CLI passes problemDescription

- With the state chip sharing the metric strip, the verifier label no longer
  fit the card width and wrapped mid-word; the tooltip already carries the
  full meaning, so keep the shield icon only.
- `lh goal create` gains `-i/--instruction` so the CLI can seed the problem
  node with the user's own words like the modal and the /goal tool do.

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

* 💄 style(goal): move node state chips to the bottom-right (review feedback)

Bottom-left read poorly in review; the state now sits on the card's
bottom-right — a task folds it into the right edge of its metric strip
(running clock rides along), other kinds right-align their footer.

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

* 💄 style(goal): status reads first — its own top row with the running clock

Neither bottom corner sat well in review. The state chip now opens the card
as its own left-aligned top row, with the elapsed clock right behind it when
the task runs; the title keeps the full width below, and with the chip out
of the metric strip the verifier label fits again.

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

* 💄 style(goal): every task state reads from the top status row

"Not dispatched" was still hiding in the metric strip while other states
had moved to the top row. A task now always carries a status chip — queued,
waiting, unassigned included — and the metric strip keeps only verifier,
attempts and artifacts.

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

* 💄 style(goal): status chips draw the task family's glyphs, not dots

A colored dot said less than it could: the status row now renders the same
glyphs the task surfaces use — the animated running ring, the dashed circle
for not-yet-dispatched, CircleX for lost, CircleCheck for done — so a node's
state reads identically everywhere.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 09:19:52 +08:00
Arvin Xu 439b0cadad 🐛 fix(cli): restore the node glyph lh goal show lost when the kind was renamed (#18912)
* 🐛 fix(cli): restore the node glyph `lh goal show` lost when the kind was renamed

`goal_nodes.kind` moved from `work` to `task` in #18801, but this glyph map
still keyed on `work`, so every task row in `lh goal show` printed
`undefined task` against a live goal.

The type checker signed off on that rename everywhere else and missed it here
for a structural reason worth fixing rather than patching around: `nodeIcon`
was an inferred `as const` object, and the root `tsconfig.json` does not
include `apps/cli`, so nothing type-checks this file except the CLI's own
standalone `tsc` — where unresolved workspace imports widen `node.kind` to
`any`, and indexing an `as const` map with `any` is legal.

Typing the map as `Record<GoalNodeKind, string>` makes the next kind change a
compile error here too: reverting the key to `work` now fails the CLI's own
type-check.

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

* ✅ test(cli): cover the node glyphs `lh goal show` renders

The type added alongside the fix proves every kind maps to *a* string; it
cannot prove the map is keyed correctly, which is the whole of the bug. This
drives `lh goal show` end to end and asserts the rendered cell.

Verified as a regression test: reverting the key to `work` fails it with the
exact output the live goal produced — `undefined task  waiting  Ask vendor A`.

The harness stringifies `console.log` calls, and `printGraph` calls it bare for
a blank line, so `String(undefined)` was manufacturing the very word the
assertion looks for. Blank calls map to an empty string instead.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:12:08 +08:00
AmAzing- 360629f1dc ✨ feat(heterogeneous-agent): add Factory Droid ACP integration (#18775)
* ✨ feat(heterogeneous-agent): add Factory Droid ACP integration

* 🐛 fix(heterogeneous-agent): address Droid ACP review feedback

* 🐛 fix(heterogeneous-agent): use official Droid installers
2026-08-31 12:46:14 +08:00