Compare commits

...

497 Commits

Author SHA1 Message Date
Innei 571697b251 🐛 fix(conversation): keep workflow errors visible 2026-05-13 16:45:02 +08:00
Innei 4b0e1911a7 🐛 fix(conversation): reserve workflow scrollbar space 2026-05-13 16:04:09 +08:00
LobeHub Bot 2cfe9f6180 🌐 chore: translate non-English comments to English in file-loaders (#14744)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:34:53 +08:00
Neko c9bb82d09d 🐛 fix(builtin-tool-memory): clarify memory retrieval sufficiency rules (#14753) 2026-05-13 15:19:43 +08:00
Rdmclin2 6933ddc4e5 🔨 chore: Online Messager (#14755)
* feat: add line integration Banner

* chore: remove messenger lab switch

* feat: add messenger banner

* feat: add messenger promo

* chore: update i18n files
2026-05-13 14:17:07 +07:00
Arvin Xu ef8aa72af5 🐛 fix(brief): add ignore action next to retry on error briefs (#14742)
*  feat(brief): add ignore action next to retry on error briefs

Lets users dismiss error briefs without re-running the task. The button
is hardcoded in the UI alongside the retry primary action; brief.actions
stays untouched.

*  feat(agent-runtime): wire trigger field across all execAgent call sites

- Add Cli / Openapi / Notify values to RequestTrigger enum
- Pass trigger:'cli' from CLI command, trigger:'openapi' from OpenAPI service
- Pass trigger:RequestTrigger.Eval from all 4 agentEvalRun call sites
- Pass trigger:RequestTrigger.Notify from agentNotify router
- Default trigger to RequestTrigger.Chat in execAgent/execAgents tRPC handler
- execGroupAgent passes trigger:RequestTrigger.Chat explicitly
- execSubAgentTask inherits trigger from parent operation (best-effort DB lookup)
- Expose trigger as optional input on ExecAgentSchema so callers can override
- Remove dead aiAgent.createOperation tRPC mutation and its frontend counterpart
- Delete test file that only covered the removed createOperation method

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

* 💄 style(loading): use shiny text animation for operation labels

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

* 🐛 fix(error): broaden heterogeneous agent error guard to match any error type

The previous guard required `error.type` to be `AgentRuntimeError` or absent,
which missed cases like `ServerAgentRuntimeError`. Extract the detection into a
proper type guard (`isHeterogeneousAgentStatusGuideError`) that checks only the
body shape (agentType + code), making it resilient to wrapper error types.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:12:24 +08:00
Neko 8618699888 🐛 fix(server/toolExecution): support server-owned memory embedding runtime (#14754) 2026-05-13 15:09:17 +08:00
Neko bfc4820a17 🐛 fix(server/userMemories): return locomo ingestion session results (#14752) 2026-05-13 15:09:10 +08:00
LiJian d8bfc58f22 🐛 fix(casc): replace new Function() template with safe string builders (#14751)
* 🐛 fix(casc): replace new Function()-based template with safe string builders and self-fetching ChangelogModal

- Remove es-toolkit/compat template (uses new Function()) from ShareModal, ShareMessageModal, and parserPlaceholder; replace with plain string building and String.replace
- ChangelogModal now self-fetches latest changelog id via lambdaClient instead of relying on async server component wrapper; setTimeout starts after data arrives
- Remove ChangelogService/gray-matter import from route component

* 🐛 fix(casc): add missing deps to changelog timer effect
2026-05-13 14:59:50 +08:00
Neko 690098dcb9 🐛 fix(agent-signal,server): both skill bundle and skill index should be considered as primary skill documents (#14748) 2026-05-13 13:11:59 +08:00
Neko a12079d338 🐛 fix(server): user id context missing in tool outcome for signal (#14749) 2026-05-13 13:11:49 +08:00
LiJian 8d1584eb78 🐛 fix(cc): preserve trailing suffix after partial deltas (#14745)
* 🐛 fix(cc): preserve trailing suffix after partial deltas

* 🐛 fix(cc): clear streamed delta buffers after reconciliation

* 🐛 fix(cc): clear streamed buffers per modality
2026-05-13 12:56:00 +08:00
LiJian c3bb289c44 🐛 fix(market-auth): add offline_access scope and guard expiresIn default (#14743)
Add `offline_access` to the OIDC authorization scope so the server
returns a refresh_token, fixing silent session expiry after ~24h.

Guard `tokenResponse.expiresIn` with `?? 3600` to prevent `NaN`
propagation into `expiresAt` when the server omits the field.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:30:07 +08:00
lobehubbot c19f87fdb2 Merge remote-tracking branch 'origin/main' into canary 2026-05-13 01:59:32 +00:00
Arvin Xu 9d03349c46 🚀 release: 20260513 (#14739)
# 🚀 LobeHub Release (20260513)

**Hotfix Scope:** Ship the canary backlog (111 PRs) onto main as a
fast-tracked patch — operator-focused, no weekly-style write-up.

> Brings the accumulated canary work into main: agent/task improvements,
hetero-agent fixes, desktop & onboarding polish, and several reliability
caps.

##  What's Included

- **Agent & tasks** — Self-review proposal-to-action automation,
sub-agent dispatch consolidated to `lobe-agent`, AskUserQuestion wiring
for Claude Code, scheduler/hotkey/TodoList polish. (#14583, #14657,
#14715, #14639, #14732, #14707, #14713)
- **Home & onboarding** — Daily brief with linkable welcome + paired
input hint, inline skill auth in recommended task templates, cleanup of
captcha-on-signin and marketplace early-exit. (#14589, #14676, #14573,
#14598)
- **Bots & integrations** — Slack MPIM support, Discord DM fix,
slash-command + connect-error fixes, gateway client-tool plugin state.
(#14733, #14591, #14596)
- **Desktop & CLI** — Windows `.cmd` shim detection for `claude` /
`codex` CLIs, auth focus & pending-login reset fixes. (#14720, #14694,
#14695)
- **Reliability** — Cap web-crawler body size and image binary at safe
limits, attach error listeners to Neon/Node pools, reject inactive OIDC
access. (#14660, #14711, #14606, #14674)
- **Database** — `agent_operations` table + persist agent operations
from the runtime; switch user memory search to `paradedb.match(...)`.
(#14416, #14736, #14590)

## ⚙️ Upgrade

- **Self-hosted:** pull the latest image and restart. Drizzle migrations
(including the new `agent_operations` table) run automatically on boot.
2026-05-13 09:58:47 +08:00
Zhijie He 1a745382b5 💄 style: add spark-x2-flash support (#14731)
* style: add spark-x2-flash support

* fix: fix deployname not send to api

fix: fix deployname not send to api

fix: fix deployname not send to api

fix: fix deployname not send to api

fix: fix deployname func

fix: fix deployname func
2026-05-13 03:08:55 +08:00
Arvin Xu a77234107e feat(agent-runtime): persist agent operations to agent_operations table (#14736)
*  feat(agent-runtime): persist agent operations to `agent_operations` table

Wire start-time INSERT and terminal UPDATE into the agent runtime so
operation history outlives the 2-hour Redis TTL. Adds
`AgentOperationModel` with `recordStart` / `recordCompletion` /
`findById` (scoped by userId so a leaked operationId can't flip another
user's row) and threads both calls through `CompletionLifecycle`, which
now owns both ends of the persistence lifecycle. Also plumbs
`parentOperationId` through `ExecAgentParams` → `OperationCreationParams`
so sub-agent invocations carry their parent lineage. Per-step aggregate
updates are intentionally out of scope.

Refs LOBE-8848

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-runtime): update CompletionLifecycle test constructor to 2 args

CompletionLifecycle now constructs MessageModel internally from
(db, userId), so the test builder passing a third messageModel arg
tripped tsgo --noEmit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:14 +08:00
Rdmclin2 729265ab5d feat: support slack mpim and fix discord dm problem (#14733)
* feat: support mpim

* chore: add errorMsg

* fix: discord commands thinking error

* fix: discord typing error

* feat: add oauth process for discord
2026-05-13 02:57:14 +08:00
Arvin Xu 5174c13ef1 🐛 fix(hetero-agent): wire AskUserBridge response events to renderer (#14732)
Close the wire-protocol gap that left CC's AskUserQuestion form stuck on
"pending" after the bridge gave up. AskUserBridge now emits an
agent_intervention_response event on every terminal path (timeout,
user resolve, cancel, cancelAll), and heterogeneousAgentExecutor handles
it by stamping pluginIntervention.status = 'rejected' for timeout /
session_ended (user-driven paths are filtered out — already optimistic).

Layered defenses so a late Submit no longer throws "Operation not found":
- cleanupCompletedOperations: find→filter so every messageOperationMap
  entry pointing to the cleaned op is removed (assistant + tool message
  pairs previously stranded one entry as a dangling reference).
- internal_getConversationContext: log + fall back to global state when
  the op has been GC'd, instead of throwing.
- submitHeteroIntervention: detect a stale opId before passing it into
  the optimistic chain.

Scoped as a short-term backstop until LOBE-8746 retires the AskUser MCP
bridge entirely.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:14 +08:00
Arvin Xu dcc9f78091 ♻️ refactor(builtin-tool): move sub-agent dispatch from lobe-gtd to lobe-agent (#14715)
* ♻️ refactor(builtin-tool): move sub-agent dispatch from lobe-gtd to lobe-agent

Move the `execTask` / `execTasks` capability out of `packages/builtin-tool-gtd/`
and into `packages/builtin-tool-lobe-agent/`, renaming the public APIs to
`callSubAgent` / `callSubAgents`. The "subtask" naming inside GTD overlapped
with the new lobe-task tool's task model and conflated planning with
sub-agent dispatch.

- API names: `execTask` → `callSubAgent`, `execTasks` → `callSubAgents`
- TS types: `ExecTaskParams` → `CallSubAgentParams`, etc.; introduce
  `SubAgentTask` to replace `ExecTaskItem`
- Client UI (Inspector / Render / Streaming) ported under
  `packages/builtin-tool-lobe-agent/src/client/`
- Central registries (`packages/builtin-tools/src/{inspectors,renders,streamings}.ts`)
  updated to register lobe-agent
- GTD `meta.description` and system role no longer mention async tasks;
  they point to lobe-agent for sub-agent dispatch
- `isSubTask` filtering in `agentConfigResolver` now excludes `lobe-agent`
  (new owner of sub-agent dispatch) instead of `lobe-gtd`
- i18n: new `builtins.lobe-agent.apiName.callSubAgent*` and
  `workflow.toolDisplayName.callSubAgent*` keys in default/zh-CN/en-US

Kept the executor's emitted `state.type` values (`execTask` / `execTasks` /
`execClientTask` / `execClientTasks`) unchanged so the agent-runtime
instruction layer (`exec_task` / `exec_tasks` / `exec_client_task*`) and all
downstream tests / heterogeneous executors (`builtin-tool-agent-management`,
server `agentManagement` runtime) continue to work without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(chat): rename isSubTask flag to isSubAgent

After moving sub-agent dispatch from lobe-gtd to lobe-agent, the flag name
no longer matches what it controls. Rename `isSubTask` → `isSubAgent` across
the chat / agent runtime layer and update related comments and test labels.

- `agentConfigResolver` context field + filter helper
- `streamingExecutor.internal_createAgentState` + `executeClientAgent`
  signatures and call sites
- `createAgentExecutors` (exec_task / exec_client_task handlers) and
  `GroupOrchestrationExecutors` (batch_exec_async_tasks)
- `chatService.createAssistantMessageStream` `resolvedAgentConfig` docs
- Test descriptions and assertions in `agentConfigResolver.test.ts` and
  `streamingExecutor.test.ts`

No behavior change — the flag's filter target (`lobe-agent` identifier) is
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(agent-runtime): rename exec_task wire identifiers to exec_sub_agent

Bring the agent-runtime "wire" naming in line with the lobe-agent
callSubAgent / callSubAgents API rename. Three layers are renamed in lockstep
to keep the bridge between tool executors and the runtime consistent:

1. Tool-emitted state.type discriminators
   - 'execTask' → 'execSubAgent'
   - 'execTasks' → 'execSubAgents'
   - 'execClientTask' → 'execClientSubAgent'
   - 'execClientTasks' → 'execClientSubAgents'

2. AgentInstruction.type and matching TS interfaces
   - 'exec_task' / 'exec_tasks' / 'exec_client_task' / 'exec_client_tasks'
     → 'exec_sub_agent' / 'exec_sub_agents' / 'exec_client_sub_agent' /
       'exec_client_sub_agents'
   - AgentInstructionExecTask → AgentInstructionExecSubAgent (and the three
     siblings)
   - ExecTaskItem → SubAgentTask

3. AgentRuntimeContext.phase + matching payload types
   - 'task_result' → 'sub_agent_result'
   - 'tasks_batch_result' → 'sub_agents_batch_result'
   - TaskResultPayload → SubAgentResultPayload
   - TasksBatchResultPayload → SubAgentsBatchResultPayload

Also renames the operation-type discriminator 'execClientTask' /
'execClientTasks' to 'execClientSubAgent' / 'execClientSubAgents' and updates
its locale string in default / zh-CN / en-US.

Tests / fixtures / mocks updated in lockstep:
- packages/agent-runtime/src/agents/{GeneralChatAgent.ts,__tests__/...}
- packages/builtin-tool-{lobe-agent,agent-management}/src/...
- src/server/services/toolExecution/serverRuntimes/agentManagement.ts
- packages/agent-mock/src/cases/builtins/todo-write-stress.ts (helper renamed
  to callSubAgent)
- src/store/chat/agents/createAgentExecutors.ts + exec-task / exec-tasks tests
  + fixtures/mockInstructions.ts (createExecSubAgent[s]Instruction)
- src/store/chat/slices/aiChat/actions/streamingExecutor.ts (phase check)
- packages/conversation-flow/src/__tests__/fixtures/**/*.json (8 fixtures
  retargeted from lobe-gtd/execTask[s] to lobe-agent/callSubAgent[s] with the
  new state.type wire values)

No behavior change — the agent runtime, executors and tests all go through
the same code paths; only the strings on the wire change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(builtin-tool): absorb GTD tool (plan + todo) into lobe-agent

Delete `packages/builtin-tool-gtd/` and fold its full surface — plan, todo,
ExecutionRuntime, all client UI (Inspector / Render / Streaming /
Intervention / SortableTodoList) and the system role — into
`packages/builtin-tool-lobe-agent/`. Single `lobe-agent` identifier now
owns: plan + todo management, sub-agent dispatch, and visual media analysis.

Also restructures the lobe-agent package so the executor lives under
`./client/` alongside the UI it ships with, and drops the dedicated
`./executor` export — consumers go through `./client` for everything
client-side.

Package-level changes:
- DELETE `packages/builtin-tool-gtd/` entirely.
- `packages/builtin-tool-lobe-agent/`
  - Move `src/executor/` → `src/client/executor/`. Drop `./executor` from
    `package.json` exports; expose `lobeAgentExecutor` via `./client` only.
  - Rename `GTDExecutionRuntime` → `PlanExecutionRuntime` and place under
    `src/client/executor/PlanRuntime/`. Re-export from package root so the
    server runtime can consume it without pulling in client UI deps.
  - Extend `LobeAgentExecutor` with `createPlan` / `updatePlan` /
    `createTodos` / `updateTodos` / `clearTodos`, all delegated to the
    shared runtime.
  - Add Plan + Todo API entries to the manifest (with their original
    descriptions, humanIntervention, renderDisplayControl).
  - Move all GTD client UI verbatim:
    `Inspector/{ClearTodos,CreatePlan,CreateTodos,UpdatePlan,UpdateTodos}`,
    `Render/{CreatePlan,TodoList}`, `Streaming/CreatePlan`,
    `Intervention/{AddTodo,ClearTodos,CreatePlan}`,
    `components/SortableTodoList`. Register them in
    `LobeAgentInspectors / Renders / Streamings`, add new
    `LobeAgentInterventions`.
  - Merge GTD system role into lobe-agent's (`<plan_and_todos>` plus the
    existing `<sub_agents>` and `<run_in_client>` sections).
  - `package.json`: pick up `@lobechat/prompts` dep and `@lobehub/editor` +
    `antd` + `lucide-react` peer-deps inherited from GTD.

Central registries (`packages/builtin-tools/src/*`) and consumers:
- Remove every `GTDManifest / Inspectors / Renders / Streamings /
  Interventions` import + registration; existing `LobeAgent*` registrations
  now cover them.
- Replace `[GTDManifest.identifier]: GTDInterventions` with
  `[LobeAgentManifest.identifier]: LobeAgentInterventions`.
- Drop `@lobechat/builtin-tool-gtd` workspace dep from
  `packages/builtin-tools/package.json`, `packages/builtin-agents/package.json`
  and root `package.json`.
- Remove `gtdExecutor` from `src/store/tool/slices/builtin/executors/index.ts`;
  switch `lobeAgentExecutor` import to `/client`.
- Replace `serverRuntimes/gtd.ts` with a service factory
  `serverRuntimes/lobeAgentPlan.ts` (`createServerPlanRuntimeService`).
  `serverRuntimes/lobeAgent.ts` instantiates `PlanExecutionRuntime` with
  that service so the registry exposes one runtime per `lobe-agent`
  identifier covering both visual analysis and plan/todo.
- `services/chat/mecha/contextEngineering.ts`: gate plan/todo injection on
  `LobeAgentIdentifier` instead of `GTDIdentifier`.
- `agentConfigResolver.test.ts`: switch fixture plugin IDs to
  `LobeAgentIdentifier`.
- `packages/const/src/recommendedSkill.ts`: drop the standalone `lobe-gtd`
  recommendation — `lobe-agent` already covers it via `defaultToolIds`.

i18n migration (default + zh-CN + en-US; other locales regenerate on
`pnpm i18n`):
- `builtins.lobe-gtd.*` → `builtins.lobe-agent.*` in `plugin.ts/json`.
- `lobe-gtd.*` (tool namespace) → `lobe-agent.*` in `tool.ts/json`.
- Remove `tools.builtins.lobe-gtd.{description,readme,title}` from
  `setting.ts/json` (lobe-agent has its own meta now).
- Update all client component `t(...)` keys to the new namespace.

Mocks / fixtures / tests:
- `packages/agent-mock/src/cases/builtins/todo-write-stress.ts`: all
  `identifier: 'lobe-gtd'` → `'lobe-agent'`; helper comments updated.
- `packages/types/src/stepContext.ts`: comment refers to
  `builtin-tool-lobe-agent` (the only consumer of `StepContextTodoItem`).
- `packages/model-runtime/src/core/streams/google/google-ai.test.ts`:
  function-call names from `lobe-gtd____createPlan` etc. → `lobe-agent____*`.
- `src/store/chat/slices/message/selectors/dbMessage.test.ts`: same.
- `src/features/DevPanel/RenderGallery/fixtures/lobe-gtd.ts` deleted; its
  plan/todo fixtures are folded into `fixtures/lobe-agent.ts` alongside the
  existing `callSubAgent[s]` ones.
- Replace `console.log` → `console.info` in moved client components to
  satisfy lobe-agent's stricter ESLint rules (GTD package allowed
  `console.log`; lobe-agent inherits the repo-wide `no-console` rule).

No behavior change for end users: `lobe-agent` now owns all the APIs,
identifiers, and UI that previously lived in `lobe-gtd`, but as a single
consolidated package under a single tool identifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(context-engine): drop residual GTD naming, rename to PlanInjector / TodoInjector

Follow-up to 9ca5c9d (which absorbed the GTD tool package into lobe-agent).
That commit moved the package surface but left the GTD vocabulary embedded
in context-engine providers, types, metadata fields, XML tags, and a pile
of comments. This change finishes the sweep so the only remaining GTD
references are user-facing docs and the legitimate Productivity & GTD Coach
methodology suggestion.

context-engine
- `GTDPlanInjector` → `PlanInjector`; types `GTDPlan`/`GTDPlanInjectorConfig`
  → `Plan`/`PlanInjectorConfig`; metadata `gtdPlanId`/`gtdPlanInjected` →
  `planId`/`planInjected`; XML tag `<gtd_plan>` → `<plan>`; debug channel
  `provider:GTDPlanInjector` → `provider:PlanInjector`.
- `GTDTodoInjector` → `TodoInjector`; types `GTDTodoItem`/`GTDTodoList`/
  `GTDTodoStatus`/`GTDTodoInjectorConfig` → `TodoItem`/`TodoList`/
  `TodoStatus`/`TodoInjectorConfig`; metadata `gtdTodo*` → `todo*`;
  XML tag `<gtd_todos>` → `<todos>`, wrapper `gtd_todo_context` →
  `todo_context`; debug channel renamed similarly.
- `MessagesEngineParams.gtd?: GTDConfig` → `planTodo?: PlanTodoConfig`;
  internal vars `isGTDPlanEnabled`/`isGTDTodoEnabled` →
  `isPlanEnabled`/`isTodoEnabled`. Re-exports updated in `providers/index.ts`
  and `engine/messages/{index,types}.ts`.

prompts
- `packages/prompts/src/prompts/gtd/` → `planTodo/` (only export was
  `formatTodoStateSummary`, which kept its name). Updated `prompts/index.ts`
  re-export.

src/services
- `contextEngineering.ts`: `GTDConfig` import → `PlanTodoConfig`;
  `isGTDEnabled`/`gtdConfig` → `isPlanTodoEnabled`/`planTodoConfig`; payload
  field `gtd` → `planTodo`; log message wording.

Tests
- `dbMessage.test.ts`: helper `createGTDToolMessage` →
  `createLobeAgentToolMessage`; `gtdMessage` → `lobeAgentMessage`; all `it`
  descriptions reworded to "lobe-agent" instead of "GTD".
- `agentConfigResolver.test.ts`: test descriptions reworded.

Comments / docs (no behavior change)
- agent-runtime (`instruction.ts`, `runtime.ts`, `generalAgent.ts`,
  `messageSelectors.ts`), `types/{stepContext,tool/builtin}.ts`,
  `builtin-agents/group-supervisor`, `builtin-tool-claude-code/types.ts`,
  `builtin-tool-lobe-agent/Render/TodoList`, `createAgentExecutors.ts:1426`,
  `AssistantGroup/{constants,Fallback.test}`, `agent-mock/todo-write-stress`,
  `.agents/skills/builtin-tool/references/architecture.md`.

Intentionally left alone
- `docs/usage/agent/gtd.{mdx,zh-CN.mdx}` and other docs — user-facing
  product brand "GTD Tools".
- `src/locales/default/suggestQuestions.ts` "Productivity & GTD Coach" —
  references the methodology, not the tool.
- `ToolSystemRoleProvider.test.ts` `'gtd-tool'` fixture — generic test
  identifier, unrelated.
- Translated locale files still carrying `lobe-gtd.*` keys — regenerated by
  `pnpm i18n` from the updated default namespace.

Verified: `bun run type-check` passes; touched test files
(dbMessage, agentConfigResolver) and full context-engine + prompts test
suites pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(builtin-tool-lobe-agent): reset TodoList auto-save status to idle

`performSave` (the debounced auto-save path) was leaving `saveStatus` stuck
on 'saved' forever — `saveNow` had the 1.5s setTimeout-to-idle but the
auto-save twin didn't, so the inline indicator never eased back to idle
after a settle. Add the same idle-reset to performSave so both paths
behave the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:14 +08:00
Arvin Xu 266d10206b 💄 style: use @lobehub/ui built-in HtmlPreview instead of custom component (#14703)
* 💄 style(home,i18n): use 已阅 for brief confirm/confirmDone in zh-CN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): use 确认完成 for brief.action.confirmDone in zh-CN

confirmDone signals the terminal transition (task marked complete),
not just dismissing the brief, so 已阅 loses the semantic distinction
from `confirm`. Use 确认完成 to match the EN intent ("Confirm complete").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: use @lobehub/ui built-in HtmlPreview instead of custom component

- Upgrade @lobehub/ui from ^5.10.1 to ^5.10.4
- Replace custom HtmlPreviewAction with lobe-ui's enableHtmlPreview
- Wire lobe-ui's onExpand callback to existing HtmlPreviewDrawer
- Remove HtmlPreviewAction.tsx (no longer needed)
- Keep HtmlPreviewDrawer for the expanded full-screen view

* 🐛 fix(task): sync useMarkdown destructuring with assistant MessageContent

* 🐛 fix(task): correct mangled search.X JSX expressions in MessageContent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(review): move revert icon to right edge of file row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
LobeHub Bot 71a49b033f 🌐 chore: translate non-English comments to English in src (#14654)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Arvin Xu fc275ca4dc 🐛 fix(home): blank user bubble when sending the placeholder hint (#14678)
When the home input was empty and the user clicked send, `useSend`
correctly fell back to the daily-brief hint for `message`, but it also
forwarded `mainInputEditor.getJSONState()` as `editorData`. An empty
editor still returns a non-null JSON state (e.g. `{ type: 'doc' }`),
which makes `UserMessageContent.hasEditorData` truthy — so the renderer
took the RichTextMessage branch and drew nothing, while the agent
happily processed the hint text behind a blank user bubble.

Skip `editorData` when the hint is being used so the renderer falls
back to the markdown `content`. Adds a regression test.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Arvin Xu cb8b616546 feat(database): add agent_operations table (#14416)
 feat(database): add agent_operations table

Adds an `agent_operations` table to persist agent runtime operations
beyond the 2-hour Redis TTL. Each row captures one agent operation
(operationId) with denormalized cost/token aggregates, lifecycle
timestamps, runtime config snapshot, and a `trace_s3_key` pointer to
the full ExecutionSnapshot in S3.

- `user_id` is intentionally not a FK so operation history survives
  user deletion (auditable historical data).
- `agent_id` / `topic_id` / `thread_id` / `task_id` / `chat_group_id`
  use ON DELETE SET NULL to preserve operations when their parent
  entity is removed.
- `parent_operation_id` self-references for sub-agent (callAgent) ops.
- `human_interventions` and `human_waiting_time_ms` are nullable since
  most operations have no human interaction at all.
- Indexes optimize per-user listing and per-status / per-entity lookups;
  `metadata` has a GIN index for jsonb filters.
2026-05-13 02:57:13 +08:00
Innei 217afcf1af 🐛 fix(conversation): prevent synthetic scroll from shrinking spacer (#14584)
🐛 fix: prevent synthetic scroll from shrinking spacer
2026-05-13 02:57:13 +08:00
Arvin Xu 2f33932198 ♻️ refactor(agent-runtime): extract CompletionLifecycle, HumanInterventionHandler, stepPresentation (#14441)
* ♻️ refactor(agent-runtime): extract CompletionLifecycle

Pull terminal-state handling out of AgentRuntimeService into a dedicated
class:

- buildLifecycleEvent (was buildCompletionLifecycleEvent)
- emitSignalEvents (was emitCompletionSignalEvents)
- dispatchHooks (was dispatchCompletionHooks)
- extractErrorMessage

These four methods formed one cohesive vertical: build the lifecycle
event payload, emit completion AgentSignal source events, dispatch
onComplete/onError hooks, and write error back onto the assistant
message row. extractErrorMessage was a private helper used by all three
plus by the trace-snapshot finalize call site, so it becomes a public
method on the class.

Call sites in executeStep / executeSync change from
`this.{emit|dispatch|extract...}` to `this.completionLifecycle.{...}`.

Tests: extractErrorMessage.test.ts → CompletionLifecycle.test.ts,
instantiating CompletionLifecycle directly instead of going through
AgentRuntimeService — drops a pile of unrelated mocks.

AgentRuntimeService.ts: 2084 → 1918 (-166).

All 81 agentRuntime tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(agent-runtime): extract HumanInterventionHandler

Pull the 165-line `handleHumanIntervention` method out of
AgentRuntimeService into its own class, splitting the three branches
(approve / rejectAndContinue / rejectAndHalt) into private methods so
each fits in one screen. Routing in `process()` now reads top-to-bottom:
detect approval, then rejection, then unsupported humanInput.

The handler depends only on `serverDB` (for the messagePlugins lookup)
and `messageModel` (for tool/plugin updates) — much narrower than
AgentRuntimeService's full surface, so the extracted unit is easier to
unit-test in isolation.

Drop the unused `runtime: AgentRuntime` parameter from the public API:
the original method threaded it through but never called it.

Tests: handleHumanIntervention.test.ts → HumanInterventionHandler.test.ts
— same 17 cases, but instantiate the handler directly instead of
constructing a full AgentRuntimeService with 11 module mocks. Tighter
arrange step, same coverage.

AgentRuntimeService.ts: 1918 → 1742 (-176).

All 81 agentRuntime tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(agent-runtime): extract step presentation builder

Pull the ~150-line `phase`-branching block out of executeStep into a
pure `buildStepPresentation` function. The block did three things in
sequence: derive content/reasoning/toolsCalling/toolsResult from the
runtime step result, build a one-line stepSummary for logging, and
assemble the StepPresentationData DTO consumed by afterStep hooks /
snapshot recorder / callbacks.

The function takes only the stepResult and an executionTimeMs; no
service state needed. Comes with a `formatTokenCount` helper for the
log line (12345 → 12.3k, 2_500_000 → 2.5m).

executeStep keeps the log call inline (one line, references presentation
fields directly) and reads `content` / `toolsCalling` off presentation
for downstream tracking + truncation logic.

13 new unit tests: phase=tool_result (json + string + isSuccess paths),
phase=tools_batch_result, done event, llm_result with content/reasoning/
tools, empty fallback, cumulative usage zero-fallback, stepUsage
forwarding, and formatTokenCount edges.

AgentRuntimeService.ts: 1742 → 1601 (-141).

All 94 agentRuntime tests pass (was 81, +13 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Arvin Xu df0e635c45 🐛 fix(task-card): localize task card date independent of dayjs global locale (#14730)
* 🐛 fix(task-card): localize date format independent of dayjs global locale

Task card was rendering "5月 12" under English UI because t('time.formatThisYear')
returned the English "MMM D" format, but dayjs's global locale was still zh-cn,
making MMM resolve to the Chinese short month name. Thread the i18n language
into formatTaskItemDate so the date is rendered with the same locale as the
format string, decoupling it from dayjs's global state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task-card): import missing GenericItemType + type Run now onClick

Pre-existing CI regression from #14727 surfacing on every PR: the Run now
context menu satisfies-clause references GenericItemType without importing
it, and the onClick lacks a MenuInfo annotation, so tsgo widens the divider
literal's `type` to `string` and rejects the whole context menu array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Arvin Xu 2202189ac1 🐛 fix(web-crawler): cap response body size to prevent serverless OOM (#14660)
* 🐛 fix(web-crawler): cap response body size to prevent serverless OOM

Production saw repeated SIGABRT crashes on `/trpc/tools/search.webSearch`
where Node aborted with V8 "allocation failed" — the naive crawler buffered
entire response bodies into heap before the 1 MB downstream truncation could
apply, so a single large page (or a batch of three under default
concurrency=3) could push rss past the lambda memory ceiling.

- ssrfSafeFetch: add opt-in `maxContentLength` that streams the response
  body via `for await` and stops at the cap (soft truncation — still a
  successful response). Breaking the iterator destroys the underlying
  stream and releases the connection. Default behaviour (full
  `arrayBuffer()` read) unchanged when the option is absent.
- naive crawler: pass `maxContentLength: MAX_HTML_SIZE` so any body beyond
  1 MB is dropped at the network layer instead of being materialised in heap.
- htmlToMarkdown: explicitly call `window.happyDOM.close()` in a finally
  block so the parsed DOM tree is released as soon as parsing finishes,
  rather than waiting for the function scope to drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(ssrf-safe-fetch): add OOM regression tests for response body cap

Verify that the maxContentLength cap actually prevents the production SIGABRT
scenario, not just produces a truncated body.

- Source-pull bound: a body source with 200 MB available, capped at 1 MB,
  must not be drained beyond ~1 MB. Asserts on bytes pulled from the
  generator, which is the property that prevents OOM.
- Concurrency bound: matches production CRAWL_CONCURRENCY=3 — three
  concurrent oversized fetches should pull at most ~3 MB total, not 300 MB.
- Heap-delta bound (gated on --expose-gc): under real GC pressure,
  fetching a 50 MB body with a 1 MB cap should grow heapUsed by < 10 MB.
  Run with `NODE_OPTIONS=--expose-gc bunx vitest run` to exercise; skipped
  by default so CI doesn't false-fail on GC timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Innei 4e4294f57e 🐛 fix(desktop): focus onboarding auth success state (#14694) 2026-05-13 02:57:13 +08:00
Arvin Xu 79152fa222 feat(markdown): user_feedback card + task card polish + Run now context menu (#14727)
*  feat(markdown): render <user_feedback> task prompt blocks as a card

`buildTaskRunPrompt` wraps the user's pre-run comments in a
`<user_feedback>` block alongside `<task>`. The Task plugin captured
`<task>` into a card, but `<user_feedback>` had no plugin and leaked
into the chat as raw XML. Because CommonMark only treats tag names
matching `[a-zA-Z][a-zA-Z0-9-]*` as html, the underscore in
`user_feedback` puts the opening/closing tags inside a `paragraph` as
plain text — so the new remark plugin walks paragraph children rather
than html nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task-card): drop standalone status row + Agent/Parent/Topics, inline semantic status badge

The status/Priority row, Agent, Parent and Topics fields aren't useful
when the task card is rendered inside the topic chat drawer (the drawer
already exposes that context). Move the task status to a compact badge
beside the identifier and reuse `taskDetail.status.*` for the label so
"scheduled" reads as "Scheduled" / "已排期".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): compact one-line header + left-border quote-style card

Slims the card down to a single 12px header line ("User feedback · N
comments") with a small 12px icon, and wraps the whole block in a
subtle fill + 2px left-border accent so it reads as a quoted aside and
visually separates from the task card that follows in the same user
message body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): drop fill + radius, render as plain left-rail blockquote

The filled card competed visually with the unstyled task block that
sits beside it in the same message body. Reducing to a 2px left-rail
quote without background or border-radius lets both blocks read as
parts of the same user message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): collapsible card with task-style head + bottom divider

Default-collapsed `<details>` whose summary mirrors the task title row
(32px icon + bold label + small count badge), with a bottom split-line
that doubles as a divider between the user feedback head and the task
card that follows in the same message body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): strip default markdown details card chrome

@lobehub/ui Markdown applies bg + padding (0.75em 1em) + box-shadow +
border-radius to every nested <details>, which made the user_feedback
head read as a wide standalone card sitting awkwardly on top of the
inline task title. Override the chrome (with !important — the lib
selector wins on specificity otherwise) so the head sits flat in the
message body, with only the bottom split line separating it from the
task that follows. The lib's right-side disclosure chevron is kept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): match task card's 12px symmetric divider spacing

Add a 12px margin-bottom so the gap below the user_feedback bottom rule
mirrors the 12px above it, matching the symmetric 12px the task card
already uses around its own internal divider. Without this, the
user_feedback rule sat flush against the T-31 row while the next rule
below T-31 had a 12px gap on both sides — visually uneven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task-card): drop status badge from task title row

The task drawer header and the schedule strip on the task detail page
already convey status; surfacing it again on the task card inside the
chat body just added noise. Drop the badge along with the now-unused
KNOWN_STATUSES / isKnownStatus / TaskStatusIcon / useTranslation
plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(tasks): add "Run now" item to task card context menu

Available only for backlog and completed tasks; mirrors the inbox-agent
fallback used by the detail-page Run Now action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(topic-list): preserve `#` icon placeholder for heterogeneous agents

Returning null for the icon slot collapsed the row layout, so titles on
heterogeneous-agent topics (Claude Code, Codex, …) no longer aligned
with sibling rows. Render the same HashIcon with visibility:hidden so
the box is preserved without showing the glyph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
brone1323 ece409195a 🌐 i18n: add missing task-schedule and review strings to 16 locales (#14728)
🌐 i18n: add missing translations for task-schedule and review keys across 16 locales

Adds 14 missing i18n keys to all non-zh-CN locales (ar, bg-BG, de-DE,
es-ES, fa-IR, fr-FR, it-IT, ja-JP, ko-KR, nl-NL, pl-PL, pt-BR, ru-RU,
tr-TR, vi-VN, zh-TW):

chat.json (11 keys):
- taskSchedule.summary.everyNHoursHalfPast
- taskSchedule.summary.hourlyHalfPast
- taskSchedule.timezoneSearchEmpty
- taskSchedule.timezoneSearchPlaceholder
- workingPanel.review.revert (and 7 sub-keys)

plugin.json (1 key):
- builtins.lobe-task.apiName.setTaskSchedule

setting.json (2 keys):
- serviceModel.modelAssignments.title
- serviceModel.optionalFeatures.title

These were added in recent commits but the automated i18n sync had not
yet propagated them to non-Chinese locales.
2026-05-13 02:57:13 +08:00
Innei e56edab711 💄 style: polish desktop header icons, sidebar density, and task menus (#14724)
* 💄 style: shrink desktop header icons and tighten sidebar/home density

Switches all desktop header action icons from DESKTOP_HEADER_ICON_SIZE to
DESKTOP_HEADER_ICON_SMALL_SIZE, and tightens vertical gaps in the home
sidebar, recents list, and nav header layout for a denser, calmer look.

* ♻️ refactor(agent-tasks): migrate task menus and scheduler select to @lobehub/ui base-ui

- TaskPriorityTag / TaskStatusTag: replace antd Dropdown with base-ui
  DropdownMenu and adopt the ContextMenuItem / MenuInfo typings.
- useTaskItemContextMenu: drop the DOM data-attribute submenu marker in
  favour of an internal activeSubmenuRef tracked via onOpenChange.
- TaskScheduleConfig / SchedulerForm: swap @lobehub/ui Select for the
  base-ui Select and replace the custom SearchBar dropdownRender with
  antd Select showSearch for timezone filtering.

* ♻️ refactor(review): migrate review dropdowns to @lobehub/ui base-ui DropdownMenu

Swap the antd Dropdown trios (mode picker, base-ref picker, more menu) in
the agent working-sidebar Review pane for the base-ui driven DropdownMenu,
matching the recent task menus / scheduler migration. Also tighten the
sidebar header paddingInline from 16 to 4 to align with the surrounding
density polish.

* 🐛 fix(tasks): replace unsupported onOpenChange with onTitleMouseEnter in context menu
2026-05-13 02:57:13 +08:00
René Wang 3a4bd4a83d fix: Docs image (#14726)
fix: image
2026-05-13 02:57:12 +08:00
René Wang 19912fe02d 📝 docs: add May 11 weekly changelog (#14651) 2026-05-13 02:57:12 +08:00
Arvin Xu a40fe91fa4 🐛 fix(desktop): detect Windows npm .cmd shims for CLI agents (claude/codex/…) (#14720) 2026-05-13 02:57:12 +08:00
LobeHub Bot ae2afe860a 🌐 chore: translate non-English comments to English in cli-migrate (#14708)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
Arvin Xu d3f8f760b2 ⬆️ chore: bump @lobehub/ui to 5.10.5 2026-05-13 02:57:12 +08:00
Arvin Xu 846e648fea 💄 style(review-panel): hover revert button to discard per-file working-tree changes (#14716)
 feat(review-panel): hover revert button to discard per-file working-tree changes

Add a hover-revealed Undo icon to each file row in the Review panel's
unstaged view. Clicking opens a Popconfirm; confirming runs a new
`git.revertGitFile` IPC that restores the file from HEAD (or unstages +
deletes when the path doesn't exist at HEAD, covering staged-add and
untracked entries).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
Innei 0007984637 feat(documents): add optimistic create/delete and inline rename for document tree (#14714)
- Insert pending rows immediately on create folder/document, with
  optimistic SWR mutation that rolls back on server error
- Auto-focus rename input on newly created items via onPendingInserted
  callback
- Defer rename commits for pending rows until the server create resolves,
  then rename against the real row id
- Optimistic recursive delete closes the confirm modal instantly, removes
  target + descendants from the tree, and rolls back on failure
- Fix folder path canonicalization in ExplorerTree rename lookup
  (toCanonicalTreePath ensures trailing slash for folders)
- Export getItemPathFromEventPath for composed-path–based item resolution
- Add unit tests for toCanonicalTreePath and ExplorerTree event helpers
2026-05-13 02:57:12 +08:00
Arvin Xu eea742fd5f fix: update Task page placeholder copy (#14704)
* fix: update Task page placeholder copy

* fix: update Task page placeholder copy (en-US)
2026-05-13 02:57:12 +08:00
Innei ca9a781bdd 💄 style: standardize header action icon sizes (#14717)
💄 style: standardize header action icons to DESKTOP_HEADER_ICON_SMALL_SIZE

Unify icon sizing across sidebar and header action buttons by replacing
hardcoded sizes and DESKTOP_HEADER_ICON_SIZE with
DESKTOP_HEADER_ICON_SMALL_SIZE for consistent visual density.

Affected components:
- SideBarHeaderLayout back button
- ToggleLeftPanelButton default size
- BackButton default size
- Agent sidebar header chevron
- InboxButton notification icon
2026-05-13 02:57:12 +08:00
Innei 18b1c25371 feat(devtools): add dev-only feature flag override panel (#14565)
Add a client-side feature flag override panel that lives behind a
floating button in dev builds. Overrides are persisted to localStorage
and merged into useServerConfigStore.featureFlags so existing flag
consumers see the toggled value without any callsite changes.

The panel is gated by NODE_ENV plus a localStorage opt-in
(LOBE_DEV_FEATURE_FLAG_PANEL_ENABLED = "1"); prod builds tree-shake
the entire feature.
2026-05-13 02:57:12 +08:00
Arvin Xu 5ff4590fc1 🐛 fix(builtin-tool-task): expose lobe-task and add setTaskSchedule (#14713)
*  feat(builtin-tool-task): expose lobe-task to users and add schedule config

The task tool is now generally available — flip it from a scenario-only
internal tool to a user-toggleable recommended skill, and let the LLM
configure recurring execution (cron or heartbeat) via createTask / editTask.

- Drop `discoverable: false` + `hidden: true` from TaskManifest registration
- Add `lobe-task` to RECOMMENDED_SKILLS so it stays installed by default
- Remove the USER_HIDDEN_BUILTIN_TOOL_IDS allowlist (only contained lobe-task);
  update selectors and AgentTool to stop filtering it out
- Extend createTask / createTasks / editTask with `automationMode`,
  `schedulePattern`, `scheduleTimezone`, `heartbeatInterval`; editTask also
  accepts `maxExecutions`
- Route schedule columns through taskService.update and maxExecutions through
  taskService.updateConfig (server merges into tasks.config.schedule);
  refresh detail once at the end of editTask

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(builtin-tool-task): split schedule config into dedicated setTaskSchedule tool

editTask was the wrong place for schedule fields — schedule needs its own
verb so the LLM (and any future human-in-the-loop review) can audit cron /
heartbeat changes separately from generic field edits, and createTask should
stay a pure "make a task" verb without automation knobs.

- Drop automationMode / schedulePattern / scheduleTimezone / heartbeatInterval
  from createTask + createTasks, and drop them plus maxExecutions from editTask
- Add new `setTaskSchedule(identifier, automationMode?, schedulePattern?,
  scheduleTimezone?, heartbeatInterval?, maxExecutions?)` API with its own
  manifest entry, executor method, types, i18n key, and inspector
- Schedule columns still route through taskService.update; maxExecutions still
  routes through taskService.updateConfig (server merges into
  tasks.config.schedule) — same wiring, just moved into the dedicated tool
- Update systemRole to advertise setTaskSchedule + keep editTask description
  clean of schedule mentions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
AmAzing- eb924ec881 feat: add service model assignments settings (#14712)
*  Add default agent model setting

* 💄 Refine service model assignments UI

* 💄 Clarify optional service model features
2026-05-13 02:57:12 +08:00
Innei 51cefe0154 🐛 fix(desktop): reset pendingLoginMethod on auth failure/cancel paths (#14695)
* 🐛 fix(desktop): focus onboarding auth success state

* 🐛 fix(desktop): reset pendingLoginMethod on auth failure/cancel paths

Clear pendingLoginMethod in authorizationFailed, authorizationProgress
cancelled, and remoteServerSyncError handlers to prevent users getting
stuck without a Get Started path when a re-auth attempt fails but a
prior authorization is still valid.

* Delete src/routes/(desktop)/desktop-onboarding/features/LoginStep.test.tsx

---------

Co-authored-by: Innei <inbox@innei.in>
2026-05-13 02:57:12 +08:00
Innei cd3716d5e7 ♻️ refactor(spa): use __DEV__ define instead of process.env.NODE_ENV (#14696)
* ♻️ refactor(spa): use __DEV__ define instead of process.env.NODE_ENV

The Vite `__DEV__` define and its global type declaration are already
in place (plugins/vite/sharedRendererConfig.ts, src/types/global.d.ts).
Replace `process.env.NODE_ENV` checks across SPA-only files with the
`__DEV__` boolean so the bundler can statically eliminate dev-only
branches in production builds.

Server-side files (app/, server/, libs/next, libs/trpc, libs/better-auth,
envs, instrumentation) and modules that are also imported by Next.js
SSR pages (e.g. components/Loading/BrandTextLoading) are intentionally
left untouched to avoid runtime `__DEV__ is not defined` errors.

* fix(vitest): define __DEV__ and related constants for test environment

Vitest runs outside the Vite SPA build pipeline, so the __DEV__ define
injected by sharedRendererDefine was not available during tests. This
caused ReferenceError: __DEV__ is not defined in any test file that
transitively imports code using the __DEV__ constant.

Add a  block to vitest.config.mts that mirrors the SPA defines:
- __DEV__: true (test is not production)
- __CI__: mirrors process.env.CI
- __ELECTRON__/__MOBILE__: false (not testing platform-specific code)

* fix: replace missed isDevEnv reference with __DEV__ in AgentMockDevtools
2026-05-13 02:57:12 +08:00
Neko def9acee66 ♻️ refactor(agent-signal,prompts,database,builtin-tool-self-iteration): unified structure of service, unified tool, unified name and concepts (#14699) 2026-05-13 02:57:12 +08:00
Arvin Xu 948e48beba 🐛 fix(utils): cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit (#14711)
* 🐛 fix(utils): cap image binary at 3.75MB so base64 payload stays under Anthropic's 5MB limit

Anthropic enforces the 5MB image cap on the base64-encoded payload, not the
binary file. Base64 inflates by ~4/3, so a 4.7MB binary file becomes 6.27MB
once encoded and trips `messages.*.content.*.image.source.base64: image
exceeds 5 MB maximum`. The previous MAX_IMAGE_BYTES of 5MB matched against
file.size, letting these images through compression untouched.

Lower the threshold to floor(5MB * 3/4) ≈ 3.75MB in both the frontend
canvas compressor and the server-side Sharp fallback so the progressive
shrink loop keeps going until the base64 payload is safely under the cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(utils): tighten image binary cap to 3MB for extra base64 headroom

Drop MAX_IMAGE_BYTES from 3.75MB (exact 5MB-base64 boundary) to a flat 3MB
so the encoded payload lands around 4MB — clear of any per-provider rounding
or jitter at the 5MB hard limit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
Arvin Xu 1ae774d55e 🐛 fix(tasks): scheduler, hotkey, comment & TodoList polish (#14707)
* 🐛 fix(portal): allow TodoList to scroll when expanded content exceeds max-height

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(tasks): route 1–N hotkey to the open submenu instead of defaulting to status

The base-ui SubmenuTrigger doesn't propagate antd's `onTitleMouseEnter`, so
the hover ref in the right-click context menu never updated and every number
press fell back to the status submenu. The standalone Priority/Status tag
dropdowns also showed 1–N hints without binding any handler at all.

- Detect the currently open submenu via `data-popup-open` + a per-submenu
  `data-task-submenu` marker on the icon; numbers are ignored when no
  submenu is open.
- Install a keydown listener on TaskPriorityTag / TaskStatusTag while their
  dropdown is open so the hint numbers actually fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(scheduler): keep Continuous unchanged while editing Max runs

Clearing the Max runs input previously emitted maxExecutions=null, which the
form re-interpreted as Continuous and auto-checked the checkbox mid-edit
(disabling the input before the user could type the replacement number).

Track Continuous as its own state derived from the persisted prop. On clear
we hold the input empty locally without touching Continuous or emitting,
and unrelated emits fall back to the persisted value so they can't flip the
checkbox either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks): always show comment Send button and unify action labels

- Make the Send button visible by default in CommentInput / FeedbackInput
  (greyed out when empty) so the field reads as an input instead of vanishing
  affordance.
- Align topic action menu labels to Title Case (Stop Run / Open Run /
  Copy Topic ID / Copy Operation ID / Copy Link) to match the rest of the
  Action microcopy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  perf(scheduler): seed SchedulerForm from props once and own state locally

The previous prop→state useEffects re-synced every time the parent prop
updated, which during the async updateSchedule → refreshTaskDetail roundtrip
clobbered the user's in-flight edits with stale store values — felt awful
on rapid changes.

Drop the three sync useEffects and seed local state from props only at
mount via a lazy useState initializer. The form now owns its values
optimistically; cross-task safety comes from `key={taskId}` on the
parent so the form remounts cleanly when switching tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): Notion-style timezone picker — drop underscores, offset on the right

Underscored labels like 'America/New_York (EST/EDT, UTC-5/-4)' read poorly in
the dropdown. Split each option into `label` (underscore → space) and `offset`,
and render the row with the city on the left and a subtle gray offset on the
right, in line with how Notion's timezone picker presents this.

IANA `value` keeps the underscore so cron and Drizzle stay happy. Search now
filters by the human label only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): keep zone abbreviations in the timezone offset column

Show 'EST/EDT · UTC−5/−4' instead of just 'UTC−5/−4' so users can recognize
the zone by its common abbreviation alongside the offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): drop awkward ':30' suffix from hourly summary

'Every hour:00' / 'Every 2 hours:30' read like glitched concatenations. Cron
storage always rounds to 0 or 30 minutes, so call out the non-zero case as
'at half past' and stay implicit on the top of the hour.

- Every hour
- Every hour at half past
- Every 2 hours
- Every 2 hours at half past

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): collapse advanced settings by default

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  perf(tasks): coalesce post-write refresh and add timezone search

Two follow-up fixes for the AgentTasks scheduler popover.

##### Optimistic schedule writes, single coalesced refresh

Rapid edits in the scheduler form (toggling daily/hourly/weekly, weekday
chips, time, etc.) each triggered `taskService.update` + a full
`internal_refreshTaskDetail` per call. With overlapping requests the
refreshes returned intermediate server state and bounced TaskTriggerTag /
summary text away from the user's latest choice.

- Add `#withCoalescedRefresh` on the task config slice: it tracks a per-task
  pending-writes count and only fires `internal_refreshTaskDetail` after the
  LAST in-flight write settles.
- Give `updateSchedule` an optimistic `internal_dispatchTaskDetail` so
  external readers see the new pattern/timezone/maxExecutions immediately.
- Route both `updateSchedule` and `setAutomationMode` through the coalescer.

##### Timezone picker — search input at the top

The dropdown had antd's implicit type-into-trigger search, which most users
miss. Add a `SearchBar` inside `dropdownRender`, filter the options against
label/value/offset locally, and show an empty state when nothing matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): weekday chips only show background when selected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(tasks): dispatch optimistic schedule under nested 'schedule' field

`TaskDetailData` exposes schedule as `schedule.{pattern,timezone,maxExecutions}`,
not flat columns. The previous optimistic dispatch used the DB-style flat keys,
which broke type-check and would never reach the in-memory selectors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks): drop Cmd+Backspace shortcut on the Delete menu item

Header dropdown only advertised the hotkey (no handler), and the right-click
context-menu handler is gone too — keeps the visual claim honest and
removes the irreversible-by-keystroke footgun.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(agent-signal): pin `now` in proposal activity tests to fixture window

Two cases relied on the real system clock; once today crossed the
fixture's default `expiresAt` (2026-05-12), pending proposals were
classified as expired and the assertions broke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks): hide '#' placeholder icon for heterogeneous agent topics

Claude Code / Codex topics aren't chat topics in the usual sense, so the
fallback HashIcon in the sidebar row reads as noise. Skip it when the
current agent has a heterogeneousProvider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🧪 test(tasks): provide agentMap in TopicItem store mock

`isCurrentAgentHeterogeneous` walks through `currentAgentConfig` which
indexes `s.agentMap[agentId]`. Extend the mocked store state to include
an empty `agentMap` so the selector resolves to `undefined` (= not
heterogeneous) instead of throwing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
Arvin Xu 94e4ea6712 🐛 fix(cli): remove stale cron entry from generated man page (#14709)
* 🐛 fix(cli): remove stale cron entry from generated man page

The cron command was removed from program.ts but the generated man page
still listed it. Regenerated via bun run man:generate.

* 🔖 chore(cli): release 0.0.15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:11 +08:00
Arvin Xu bfa28506af 💄 style(tool): add word wrap toggle to tool arguments display (#14706)
 feat(tool): add word wrap toggle to tool arguments display

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:11 +08:00
Rdmclin2 fdedc9697d 🐛 fix: sidebar add agent (#14693)
* fix: sidebar add agent and group error

* feat: add billboard cta
2026-05-13 02:57:11 +08:00
Innei 877052fc1f 💄 style(nav): unify ActionIcon sizing and improve TodoList encapsulation (#14692)
- Extract SIDEBAR_HEADER_ACTION_ICON_SIZE constant for consistent sidebar header ActionIcon sizing
- Pass size prop to ToggleLeftPanelButton
- Simplify Agent selector ActionIcon to use 'small' size preset
- Move layout wrapper styles from Body into TodoList root for better component encapsulation
- Increase Nav gap from 1 to 4 for proper spacing
2026-05-13 02:57:11 +08:00
YuTengjing 4490e3ef76 feat: inline skill auth in recommended task templates (#14676)
*  feat: support refreshing recommended task templates

- Add optional `refreshSeed` through `listDailyRecommend` API, service, and
  client; SWR key includes it so a refresh actually refetches.
- Frontend stores the seed in sessionStorage (via `useSessionStorageState`)
  so a new tab or next day returns to the default daily picks.
- Home Daily Brief shows a "Refresh" affordance on the Recommendations
  subtitle row.
- Fix first-card pinning when matched candidates < RECOMMEND_COUNT: fold
  the fallback pool in so seed reorders the whole batch instead of locking
  position 0 to a single-match template.

Linear: LOBE-8689

*  feat: resolve task-template icon priority

Render the task-template card icon as self > skill provider > interest > Sparkles. Skill icons read required[0] then optional[0], skipping unresolvable providers. URL icons render via @lobehub/ui Image, component icons keep the 28x28 tile.

*  feat: inline skill auth in task template card

Single click "Add task" is now the entire flow: the button stays put, and if a required skill is missing we chain its OAuth popups and create the task automatically. Unauthorized providers (required + optional) appear as compact inline rows above the footer; the provider that already drives the card's main icon is suppressed to avoid duplicating the same logo.

*  feat: add task template detail modal

Open a detail modal when the recommended task template card is clicked,
exposing the full instruction (markdown) plus inline skill auth and the
add-task action. Rename i18n `${id}.prompt` -> `${id}.instruction` to
align with the task table column, and write both `description` and
`instruction` when creating the task. Extract shared `TemplateBriefIcon`,
`useScheduleText`, `useTaskTemplateCreate` and `useVisibleAuthSpecs` so
the card and the modal share the same creation flow and OAuth chaining.

* 🐛 fix: missing Block import in TaskTemplateCard

*  feat: render recommended templates on empty Tasks page

Replace the bare "no tasks" placeholder with a hero landing: greeting,
enlarged inline composer (hero variant), and a 2-column grid of up to
10 recommended task templates. Plumbs a new `count` option through the
service, both routers, the client service, and the recommendations hook
so the home page keeps its 3-card layout while the empty Tasks page
asks for 10.

* 🐛 fix: type cast in resolveTemplateIcon test for unknown interest

* 🌐 i18n: update translations for task template empty-state and other namespaces
2026-05-13 02:57:11 +08:00
Innei 7349ad0f53 🐛 fix: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop (#14689)
Migrate all ScrollShadow usages to ScrollArea (scrollFade) to eliminate
the effect → setState → render → effect cycle that caused React error
#185 (Maximum update depth exceeded) in the scroll overflow hook.

Affected components:
- StreamingMarkdown
- AgentCouncil AutoScrollShadow
- AssistantGroup ContentBlocksScroll
- Conversation Thinking

Fixes lobehub/lobehub#14650
2026-05-13 02:57:11 +08:00
LiJian 744059c1bc 🐛 fix(heteroFinish): trigger task lifecycle on cloud sandbox agent completion (#14681)
* 🐛 fix(heteroFinish): trigger task lifecycle transition on sandbox agent completion

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

* 🐛 fix(heteroFinish): guard onTopicComplete against duplicate finish calls

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:11 +08:00
LiJian aa4533e6cb 📝 docs(cloudHeteroContext): add sandbox persistence & gh push rules (#14682)
* 📝 docs(cloudHeteroContext): add sandbox persistence & gh push rules

Inject ephemeral-sandbox warnings and mandatory GitHub push rules into
the cloud CC context block so every Claude Code run knows:
- The sandbox is wiped after inactivity — local changes will be lost
- All code changes must be committed and pushed before task is complete
- Use gh CLI (pre-authenticated) for GitHub operations

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

* 🐛 fix(cloudHeteroContext): address review comments on sandbox persistence rules

- Remove gh push guidance (gh has no push subcommand; git push is correct)
- Gate gh-auth instructions behind githubToken availability to avoid
  auth-dependent commands failing in no-token sandbox runs

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

* 📝 docs(cloudHeteroContext): add git push auth fallback guidance

Tell CC that the sandbox has git credentials ready, but if git push
fails it can self-recover via:
1. gh auth setup-git (reconfigures git credential helper)
2. inline token URL as last resort (oauth2:$GITHUB_TOKEN@github.com)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:11 +08:00
YuTengjing ea1d926de4 📝 docs(skills): frontmatter cleanup + argument-hint (#14683)
* 🔨 chore: control skill triggering via frontmatter flags

- Rename debug skill to debug-package (avoid confusion with debugging workflows)
- Add disable-model-invocation to add-* skills so they are manual-only
- Add user-invocable: false to reference/architecture skills so they auto-load only when relevant

* 🔨 chore: rename skill reference dirs to plural references

Align with the skill-creator convention (scripts/, references/, assets/).

* 📝 docs(skills): split oversized SKILL.md files and refine triggers

- upstash-workflow: 1126L → 189L, extract implementation / best-practices / examples references
- data-fetching: 854L → 613L, move parent-keyed-map walkthrough to references
- store-data-structures: 625L → 314L, extract types and reducer references
- upstash-workflow/cloud.md, version-release/release-notes-style.md: add TOCs
- linear: rewrite ALL-CAPS MUSTs into prose explaining why; mark user-invocable: false
- version-release: mark disable-model-invocation: true (manual /version-release only)
- debug-package: expand description with concrete trigger phrases and tokens

* 📝 docs(skills): regularize microcopy structure

Move language-specific guidelines into references/zh.md and references/en.md
so SKILL.md can point to them via the standard progressive-disclosure pattern.
Previously the two files sat next to SKILL.md but were not referenced anywhere,
making them invisible to Claude Code loading.

* 📝 docs(skills): move builtin-tool refs into references subdir

Aligns builtin-tool with the references/ layout used elsewhere
(microcopy, store-data-structures). 3 md files move, SKILL.md
links updated.

* 📝 docs(skills): broaden trigger descriptions for core skills

Adds concrete API names, file paths and natural-language phrases so
auto-triggering catches more relevant prompts. Touches zustand,
drizzle, i18n, react, typescript, modal, hotkey.

* 📝 docs(skills): add argument-hint to user-only skills
2026-05-13 02:57:11 +08:00
𝑾𝒖𝒙𝒉 dfe19323b8 🐛 fix(hotkey): remove redundant onClear to prevent double updateHotkey calls (#14663)
Previously, clicking the clear button on HotkeyInput triggered both
`onClear` and `onChange` (since HotkeyInput internally calls
`setHotkeyValue('')` which fires `onChange`). This caused two
concurrent requests to `updateDesktopHotkey` and showed two toast
messages (success/error) for a single user action.

Fix: remove the redundant `onClear` prop. HotkeyInput's clear action
already fires `onChange('')`, so the single `onChange` handler is
sufficient.

Co-authored-by: Innei <i@innei.in>
2026-05-13 02:57:10 +08:00
Innei 0e58fa7126 ♻️ refactor(web-onboarding): merge agent-marketplace identifier into onboarding tool (#14672)
* ♻️ refactor(web-onboarding): merge agent-marketplace identifier into onboarding tool

Drop the standalone `lobe-agent-marketplace` builtin tool and fold its
`showAgentMarketplace` / `submitAgentPick` APIs into `lobe-web-onboarding`
so onboarding exposes a single tool identifier.

- Move marketplace API entries (with humanIntervention/renderDisplayControl)
  into WebOnboardingManifest; extend WebOnboardingApiName.
- Compose AgentMarketplaceExecutionRuntime inside WebOnboardingExecutionRuntime;
  the client WebOnboardingExecutor now owns showAgentMarketplace/submitAgentPick
  with telemetry hooks. Drop the separate client/server executor + runtime files.
- Merge marketplace Inspector / Intervention / Render maps under the
  web-onboarding identifier. Remove AgentMarketplace* entries from
  builtin-tools registries and from the builtin web-onboarding agent's
  plugins list.
- Switch customInteractionHandlers to route by (identifier, apiName) so
  the marketplace picker handler fires only on `showAgentMarketplace`.
- Drop the `lobe-agent-marketplace` fallback string in
  OnboardingActionHintInjector; match by apiName only.
- Rename plugin/setting locale keys under `lobe-web-onboarding.*`.

* 🐛 fix(onboarding): reserve scroll headroom for agent marketplace overlay

- Add a footerSlot spacer in ChatList matching the marketplace panel height so the latest message can be scrolled into view above the absolute overlay.
- Nudge the marketplace overlay inset by 2px to hide subpixel border seams.
- Document turn output order in the onboarding system role to avoid trailing filler text after tool calls.
2026-05-13 02:57:10 +08:00
YuTengjing b79c5d8e70 🐛 fix: reject inactive OIDC access (#14674)
* 🐛 fix: reject inactive OIDC access

* 🐛 fix: honor expired OIDC bans

* 🐛 fix: decouple OIDC inactive error from tRPC

*  test: fix OIDC auth type checks
2026-05-13 02:57:10 +08:00
Arvin Xu f591f7ac34 💄 style(web-onboarding): add Render for saveUserQuestion & showAgentMarketplace (#14667)
 feat(builtin-tool-web-onboarding): add Render for saveUserQuestion + showAgentMarketplace

Tool messages for `saveUserQuestion` and `showAgentMarketplace` previously
fell back to the raw Arguments/Response table once the call resolved
because neither API had a Render registered. Wire both up:

- `saveUserQuestion`: new Render mirroring the Intervention's detail-card
  style — agent identity (emoji + name), full name, and interests chips —
  rendered conditionally per the fields actually saved.
- `showAgentMarketplace`: reuse the existing `SubmitAgentPick` Render.
  After the picker submits, `customInteractionHandlers` rewrites the
  `showAgentMarketplace` tool message's `pluginState` to the same
  `{ summaries, installedAgentIds, ... }` shape, so the card grid
  renders without a new component.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:10 +08:00
Arvin Xu 3f43e69fa6 ♻️ refactor(knowledge-base): share RAG runtime across client/server via KnowledgeBaseSearchService (#14673)
* ♻️ refactor(knowledge-base): share runtime across client/server via KnowledgeBaseSearchService

Extract a server-side `KnowledgeBaseSearchService` (semanticSearchForChat
fan-out + getFileContents branching + groupAndRankFiles) so both the lambda
chunk router and the builtin tool server runtime orchestrate RAG through one
implementation. Wire the builtin knowledge-base tool to the shared
ExecutionRuntime in the package by moving the client executor to
`src/client/executor/` and registering a thin server runtime factory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(knowledge-base): move PG 23505 handling into adapters, restore executor path

ExecutionRuntime is dual-end so it cannot detect PG error codes — only the
server adapter can. Move the unique-constraint check there and translate the
lambda router's `FILE_ALREADY_IN_KNOWLEDGE_BASE` sentinel in the client
adapter, so the runtime's generic catch surfaces the human-readable message
on both code paths. Restore `src/executor/` as a top-level sibling of
`src/client/` to match the convention of every other builtin tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(knowledge-base): collapse executor into /client, drop ./executor export

The executor is just another client-only adapter (alongside Inspector and
Render) — no reason for it to sit at the package root with a dedicated
subpath. Move it under `src/client/executor/`, re-export from
`src/client/index.ts`, drop the `./executor` entry from package.json, and
update the consumer to import from `@lobechat/builtin-tool-knowledge-base/client`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(knowledge-base): cover KnowledgeBaseSearchService

13 unit tests across both methods:
- getFileContents: docs_* direct read, missing doc, file_* via findByFileId,
  parseFile fallback, parse failure surfaces as error entry, missing file,
  mixed batch.
- semanticSearchForChat: chunk grouping + relevance ranking, BM25 skip when
  no knowledgeIds, knowledgeIds → fileIds expansion, vector/BM25 isolated
  failure capture (preserves the other path's results + structured
  rejections), full failure path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:10 +08:00
Arvin Xu 314619d798 ♻️ refactor(bot): close activator bypass + converge device-access checks (#14664)
* ♻️ refactor(aiAgent): introduce deviceToolRegistry as single source of truth

Centralise "what counts as a device tool" into one module so the next
device-tool addition only touches one file. Removes the hardcoded
`new Set(['local-system', 'remote-device'])` from `deviceToolAudit.ts`,
which had drifted from `LocalSystemManifest.identifier` /
`RemoteDeviceManifest.identifier` imports elsewhere.

Foundation for the LOBE-8768 activator-bypass fix landing next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(aiAgent): block activator from bypassing canUseDevice gate

External bot senders could still reach the owner's machine by having the
LLM call `lobe-activator.activateTools(["lobe-remote-device"])`, because
`enableCheckerFactory.allowExplicitActivation` short-circuits before the
canUseDevice rule, and the engine's `manifestSchemas` always contained
the full builtin list (LOBE-8768 B1).

Fix by filtering builtin manifests **physically** through
`buildAllowedBuiltinTools` at both feed-points (ToolsEngine input and
the activator-discovery `toolManifestMap`). When `canUseDevice=false`,
the device manifests no longer exist in either map, so explicit
activation cannot resolve them — the rule-layer gate becomes
defense-in-depth instead of the sole barrier.

Validates with the prod incident's repro path: an external sender's
`<available_tools>` no longer advertises `lobe-remote-device`, and an
activator call to enable it returns "not found".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(bot,messenger): centralise isOwner derivation in buildBotContext

The same fail-closed expression
`!!operatorUserId && senderExternalUserId === operatorUserId` was
duplicated across `BotMessageRouter.onNewMention`, `.onSubscribedMessage`,
the DM catch-all, and `MessengerRouter.dispatchToAgent` — four sites,
one rule, one place to silently regress.

Route all four through `buildBotContext`. The helper now owns the
fail-closed contract referenced by `ChatTopicBotContext.isOwner`'s
docstring, so adding the next platform/router can't accidentally
default to "trusted when in doubt".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(aiAgent): apply device filter post-merge across all manifest sources

The previous fix only filtered the `builtinTools` source. An installed
plugin or a Skill/Klavis manifest declaring
`identifier: 'lobe-remote-device'` would still survive in
`manifestSchemas` and reach `toolManifestMap` via either
`getEnabledPluginManifests` or the direct ingest loops in
`aiAgent/index.ts` — letting an external bot sender activate the device
identifier through the activator.

Two changes close the gap:

  1. `ServerAgentToolsEngineConfig.excludeIdentifiers` — applied **after**
     combining plugin + builtin + additional manifests in
     `createServerToolsEngine`. `createServerAgentToolsEngine` passes
     `DEVICE_TOOL_IDENTIFIERS` whenever `canUseDevice` is false.

  2. `isManifestIngestAllowed` in `aiAgent.execAgent` — a single
     identifier guard reused at every `toolManifestMap` / `toolSourceMap`
     write (engine-returned plugin manifests, lobehub-skill loop,
     klavis loop). New ingest points inherit the wall automatically.

New test pins the regression: a plugin + an additional manifest
spoofing the device identifiers are dropped from `availablePlugins`
when `excludeIdentifiers` is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:10 +08:00
Arvin Xu d9fe275a4c ♻️ refactor(task): snapshot agent model into task.config at create time (#14670)
*  feat(task): snapshot agent model into task.config at create time

Pin the assignee agent's current model/provider into task.config when a
task is created so later changes to the agent's default model don't
silently affect already-created tasks. On first run, backfill the
snapshot for tasks created before this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task-runner): fall back to inbox agent when task has no assignee

`TaskRunnerService.runTask` previously threw `BAD_REQUEST` for any task
without `assigneeAgentId`, which broke runs created without `--agent`.
Resolve and persist the user's built-in inbox agent instead, surfacing
an `INTERNAL_SERVER_ERROR` only if that resolution itself fails.

Picked from #14671 (closes once landed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(task): collapse router orchestration into TaskService

Move multi-step task verbs out of the TRPC router into `TaskService`:
`createTask`, `cancelTopic`, `deleteTopic`, `runReview`, `updateStatus`,
`previewSubtaskLayers`, `runReadySubtasks`. The router keeps only input
validation + error wrapping; the tool runtime now shares the same
`createTask` path (was duplicating the model snapshot + parent
resolution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🚨 ci: fix tsgo errors from TaskService extraction

`runReadySubtasks` router was rebuilding the `data` payload via a
conditional spread, which forced TS to infer a discriminated union that
broke `result.data.skipped` access in the integration test. Pass the
service result straight through so `skipped` stays a single optional
field. Also cast the stubbed `taskService` in the tool runtime unit
tests to bypass strict structural typing — same pattern the other
dep stubs already use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:10 +08:00
YuTengjing 03b3e2fc12 🔥 chore: drop task template tracking (#14666)
* 🔥 chore: drop task template tracking

The recommendation surface is about to be redesigned, so the analytics
funnel added in #14517 is being removed up front. A fresh tracking
schema will land alongside the redesigned UI.

- Delete `analytics.ts` plus its test and the tracking-focused
  `TaskTemplateCard.test.tsx`.
- Drop `RecommendedTaskTemplate` / `TaskTemplateRecommendationSource` /
  `TaskTemplateFallbackPool` and revert the service to plain
  `TaskTemplate[]`.
- Strip impression, dismiss, create-clicked/result and
  skill-connect-clicked/result calls from `TaskTemplateCard.tsx`, while
  keeping the createTask + navigate-to-task flow from #14540.
- Remove `recommendationBatchId` / `userInterestCount` / `onCreated`
  plumbing from `useDailyBriefRecommendationsUI`,
  `DailyBriefRecommendationsView`, and the card props.
- Revert `useSkillConnection` to the pre-tracking variant (no
  onConnectResult / SkillConnectionResult).

* 🐛 fix: remove created template from recommendation cache

After #14540 changed the create-task flow to auto-navigate to
`/task/{id}`, removing the `onCreated` plumbing from #14517 in the same
sweep meant the SWR recommendation cache was never mutated on success.
Combined with the server-side `recordCreated` being a no-op and
`listDailyRecommend` not excluding created IDs, returning to Home
showed the same recommendation as actionable again — letting users
trigger duplicate scheduled tasks from the same template.

Re-add the minimal cache-eviction plumbing (no analytics):

- TaskTemplateCard exposes `onCreated` and calls it on success
- useDailyBriefRecommendationsUI shares `removeTemplateFromList` for
  both dismiss and created flows
- DailyBriefRecommendationsView passes `onCreated` through
2026-05-13 02:57:10 +08:00
YuTengjing b0ee35dd35 🐛 fix: drop unreachable aihubmix empty-apiKey test (#14669)
* 🐛 fix: drop unreachable aihubmix empty-apiKey test

The `should return empty array when API key is missing` test asserts a
contract that doesn't hold: RouterRuntime.models() constructs the
underlying runtime via the OpenAI-compatible factory before calling
modelsOption, and the factory throws InvalidProviderAPIKey on empty
apiKey at construction time — so aihubmix's own `if (!apiKey) return []`
short-circuit can never actually fire.

Just delete the dead test. The defensive guard in aihubmix's modelsOption
stays as intent documentation. Also tighten an implicit-any in the
adjacent `should normalize model_id field to id` test.

* 🔥 chore: drop dead empty-apiKey guard in aihubmix modelsOption

* 💄 style: tighten aihubmix apiKey assertion to string
2026-05-13 02:57:10 +08:00
Zhijie He a1fac45b3a 💄 style: add reasoning_effort support for Grok 4.3 (#14642)
* style: add reasoning_effort for Grok 4.3

* style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)

style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)

style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)
2026-05-13 02:57:10 +08:00
Arvin Xu e0ead0c47a 💄 style: increase chat topic title length (#14659)
* 💄 style: increase chat topic title length

- bump initial topic title slice from 20 to 40 chars
- bump dev fallback slice from 30 to 40 chars
- bump thread title slice from 20 to 40 chars
- raise LLM summary title prompt limit from 50/10w to 80/15w

* 💄 style: bump topic/thread title slice from 40 to 80 chars

Align slice limits with the LLM summary prompt cap (80 chars) so the
initial visible title is no shorter than what the summarizer can return.
2026-05-13 02:57:10 +08:00
Bianzinan f4de472e82 fix(aihubmix): use full models endpoint to return complete model list (#14511)
* fix(aihubmix): use full models endpoint to return complete model list

The /v1/models endpoint at api.aihubmix.com returns only per-user-group
models (~256). The new endpoint at aihubmix.com/api/v1/models returns
the complete catalog (800+). Fetch from the full endpoint directly.

* fix(aihubmix): normalize model_id to id from full models endpoint

The https://aihubmix.com/api/v1/models endpoint uses `model_id` instead
of `id`. Map it to `id` before passing to processMultiProviderModelList
to prevent toLowerCase() errors and empty model list.

* fix(aihubmix): add apiKey guard, AbortController timeout, and better error messages

- Extract apiKey with runtime guard to fail fast when key is missing
- Add AbortController with 10s timeout to prevent indefinite hanging
- Include response body in error message for easier debugging
- Add APP-Code header comment pointing to docs
- Expand tests: mock global fetch, cover missing key / HTTP error / network error / AbortError cases

* fix(aihubmix): add field mapping adapter and fix timeout scope

Address review feedback from #14511:

- Update AiHubMixModelCard interface to reflect the new endpoint schema
  with full JSDoc (model_id, desc, types, features, input_modalities,
  context_length, max_output, pricing.cache_read/cache_write)
- Add mapAiHubMixModel() to adapt API response fields to LobeHub model
  card fields before passing to processMultiProviderModelList:
    desc             -> description
    model_name       -> displayName
    context_length   -> contextWindowTokens
    max_output       -> maxOutput
    types            -> type  (llm/t2t->chat, image_generation/t2i->image,
                               video/t2v->video, tts, stt, embedding,
                               rerank/reranking->rerank)
    pricing.cache_read  -> pricing.cachedInput
    pricing.cache_write -> pricing.writeCacheInput
    features(tools/function_calling) -> functionCall
    features(thinking)               -> reasoning
    features(web)                    -> search
    input_modalities(image)          -> vision
- Fix timeout scope: move clearTimeout into the finally block so the
  AbortController stays active during response.json() body read, not
  just during the initial fetch() call
- Update baseURL from https://api.aihubmix.com to https://aihubmix.com
  to match official integration docs (https://docs.aihubmix.com/cn/api/Aihubmix-Integration)
- Strengthen normalize test: assert list.some(m => m.id === 'some-model')
  instead of just Array.isArray to detect normalization failures
- Add field-mapping test using vi.spyOn on processMultiProviderModelList
  to assert that all adapted fields are passed correctly

* fix(aihubmix): filter out unsupported rerank types to prevent chat fallback

- Remove rerank/reranking from TYPE_MAP; they have no LobeHub AiModelType
  equivalent and would silently fall back to 'chat' in processModelCard
- Add UNSUPPORTED_AIHUBMIX_TYPES set and filter before mapAiHubMixModel()
- Add regression test asserting rerank/reranking models are excluded and
  llm models still pass through

---------

Co-authored-by: Bianzinan <bianzinan@users.noreply.github.com>
2026-05-13 02:57:10 +08:00
Innei 5f14b7e463 feat(activator): require activation reason (#14597) 2026-05-13 02:57:09 +08:00
Innei a9eb904cf4 🐛 fix(onboarding): skip marketplace on early exit, drop CJK in prompts (#14598)
* 🐛 fix(onboarding): skip marketplace on early exit, drop CJK examples in prompts

Honor the user's wish to leave: when the onboarding agent detects a true
early-exit signal in any phase, persist what is known, send a brief
farewell, and call finishOnboarding directly. The marketplace handoff is
mandatory only on normal Phase 4 / Summary completion. Previously the
spec forced the agent to invent categoryHints from environment cues
when discovery was thin, producing noisy recommendations for users who
explicitly asked to stop.

- Replace systemRole §Early Exit with a 4-step flow (no marketplace, no
  summary), and remove the trailing "respect their time" rationale that
  contradicted the new policy.
- Update toolSystemRole turn-protocol exception accordingly; mark
  persistence as best-effort (do not retry on failure) since the
  Pre-Finish Checklist is overridden on early exit.
- Update OnboardingActionHintInjector L101/L127 hints to match the new
  flow, and append an EXCEPTION clause to the Summary not-opened hint
  so a true exit signal in Summary skips the marketplace too.
- Strip CJK example phrases from prompt text; rely on the LLM's
  multilingual recognition with "equivalents in any language" hints.

* 🔨 refactor(FollowUpChips): remove unused consume function and reset editor state on chip click
🔨 style(InterventionBar): remove overflow hidden from container style

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(ci): align FollowUpChips test with removed consume and increase timeout for PGlite cold-start

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-13 02:57:09 +08:00
Neko 1374fd29e8 feat(agent-signal,server,prompts): consolidate in self-review implemented (#14657) 2026-05-13 02:57:09 +08:00
Arvin Xu 31e9130cf0 💄 style(hetero-agent): read-only SubAgent threads with breadcrumb header and thread switcher (#14658)
*  feat(hetero-agent): read-only SubAgent threads with breadcrumb header and thread switcher

- Hide chat input on SubAgent threads (execution is driven by the parent agent) and replace it with an inline read-only hint
- Render the hint as the last item inside the virtual list so it scrolls with messages instead of being pinned to the viewport bottom
- ChatList exposes a new `footerSlot` prop that VirtualizedList injects as a synthetic trailing data item
- Header now shows `topic / thread` breadcrumb; thread title is a popover trigger that lists sibling threads in the same topic for one-click switching
- Hide the working-directory tag while inside a thread — directory switching doesn't belong in this read-only view
- Unify user-facing strings to "SubAgent" (badge, hint, open/close labels)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(chat-input): soften queue tray preview borders

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(conversation): scrollToBottom lands on the true last VList item

scrollToBottom targeted displayMessages.length - 1, which leaves any
trailing synthetic items (spacer, SubAgent footer hint) below the
viewport. In SubAgent threads this kept atBottom = false after the
BackBottom click or auto-scroll, so the button appeared stuck.

VirtuaScrollMethods now exposes getTotalCount, which VirtualizedList
fills from the live data length (messages + spacer + optional
footerSlot) via a ref. scrollToBottom uses that to scroll to the real
last index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Arvin Xu 84b802cf96 💄 style(chat-input): show skeleton in action bar while config is loading (#14656)
* 💄 style(chat-input): show skeleton in action bar while config is loading

Before agent / group config hydrates, action buttons read DEFAULT_*
fallbacks and the send button would dispatch against a not-yet-ready
target. Add an `isConfigLoading` prop on DesktopChatInput that swaps the
action bar + send area for skeleton placeholders. The chat page passes
`agentSelectors.isAgentConfigLoading`, group chat passes
`agentGroupSelectors.isGroupsInit`. The editor itself stays usable so
users can start typing immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(home,i18n): use 已阅 for brief confirm/confirmDone in zh-CN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): use 确认完成 for brief.action.confirmDone in zh-CN

confirmDone signals the terminal transition (task marked complete),
not just dismissing the brief, so 已阅 loses the semantic distinction
from `confirm`. Use 确认完成 to match the EN intent ("Confirm complete").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): use "Confirm complete" for brief.action.confirmDone in en-US

Match the semantic distinction the call site relies on:
`confirm` is dismiss-only for recurring scheduled runs, while
`confirmDone` marks the terminal completion transition. The test
mock already used "Confirm complete" — align the source defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Arvin Xu e261a6ff98 💄 style(home): add Recommendations module with hetero agent action library (#14645)
*  feat(home): add Recommendations module with hetero agent action library

Introduce a `Recommendations` section that renders above the existing daily-brief
task templates. The module is driven by an extensible action registry with per-action
eligibility checks; the first registered actions surface "Add Claude Code agent" and
"Add Codex agent" cards on desktop when the matching local CLI is detected and the
user hasn't added that hetero agent yet.

- New `src/features/Recommendations/` with action types, registry, hetero-agent
  factory, eligibility hook, parallel CLI detection (SWR-cached) and card UI.
- Extract `createHeterogeneousAgent` from `useCreateMenuItems` into a shared
  `useCreateHeteroAgent` hook so the sidebar menu and Recommendations card share
  one creation path (create + refresh sidebar + navigate to chat).
- `DailyBrief` now renders `<Recommendations />` in place of the standalone
  template-only section; visibility is driven by the new
  `useRecommendationsVisible` hook.
- Add `recommendations.*` i18n keys to the `home` namespace (default + zh-CN +
  en-US dev preview).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(home): polish Recommendations card with brand avatar and tighter copy

Use brand Avatar icons with rounded square shape, drop the duplicate title, and tighten copy (Coding Agent tag, Add Agent CTA).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Rdmclin2 3fb8daaa08 🔨 chore: optimize system bot (#14649)
* feat: add already consumed alert

* feat: support slack send slack commends  emphemeral in channel

* chore: handle parse commands imperial

* fix: slack messenger callback ok

* feat: add messager connectionId per user

* fix: add userId to webhookbody

* fix: test case
2026-05-13 02:57:09 +08:00
Arvin Xu 49c3d7e367 feat(hetero-agent): support AskUserQuestion tools for claude code (#14639)
*  feat(hetero-agent): AskUserQuestion MCP server + bridge skeleton (LOBE-8725 step 1+2)

Foundation for LOBE-8725 — interactive AskUserQuestion via local MCP. CC's
built-in tool short-circuits in `-p` mode, so we host an in-process MCP
server that exposes an equivalent `ask_user_question` tool. The handler
blocks until the consumer submits an answer (or the 5min deadline / op
shutdown fires), surfacing a structured `agent_intervention_request` /
`agent_intervention_response` round-trip on the existing event stream.

Added in this commit:

- `packages/heterogeneous-agents/src/askUser/`
  - `AskUserBridge` — per-op pending map with timeout / cancel / progress
    keepalive support; emits an async-iterable of outbound events
  - `AskUserMcpServer` — process-wide HTTP/Streamable MCP server,
    `?op=<id>` query routes via `AsyncLocalStorage` →
    `onsessioninitialized` → sessionId↔opId map; tool handler hands off
    to the matching bridge and pumps `notifications/progress` back to CC
    every 30s as wire-level keepalive (required for >5min waits, see
    spike notes)
  - `constants.ts` — shared tool/server names + the stable `apiName`
    the adapter rewrites to
  - Unit tests cover bridge lifecycle (resolve / cancel / timeout /
    progress / event stream) and an end-to-end MCP probe via
    `StreamableHTTPClientTransport`

- `packages/agent-gateway-client/src/types.ts` — wire-level
  `agent_intervention_request` / `agent_intervention_response` event
  variants + payload interfaces. Re-exported through the package barrel.

- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` — when CC's
  `tool_use` carries `mcp__lobe_cc__ask_user_question`, the adapter
  rewrites `apiName` to `askUserQuestion` so the renderer routes on a
  clean domain key. Identifier stays `claude-code`. Applied to both the
  main-agent and subagent paths for symmetry (subagent ask isn't
  expected today, but doesn't hurt).

- `src/server/routers/lambda/aiAgent.ts` — Zod input schema for
  `aiAgent.heteroIngest` extended with the two new event types so the
  CLI sandbox can forward them through the server.

No producer wiring yet — Steps 3-5 plug this into Electron main, the
renderer executor, and the new UI.

*  feat(hetero-agent): wire AskUserQuestion MCP into Electron CC driver (LOBE-8725 step 3)

Plug the Step 1 skeleton (`AskUserMcpServer` + `AskUserBridge`) into the
desktop Claude Code spawn path. CC's local MCP `ask_user_question` tool now
goes live during real prompts; renderer-submitted answers route back via
new IPC.

Changes
- `apps/desktop/src/main/modules/heterogeneousAgent/types.ts` — add
  optional `mcpConfigPath` to `HeterogeneousAgentBuildPlanParams` so
  controller-managed temp configs flow into the driver.
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`
  — append `--mcp-config <path>` when provided. Disallowed-tools pin
  stays so CC's built-in AskUserQuestion remains off (avoids double-
  registration of the same tool name).
- `apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
  - Lazy-singleton `AskUserMcpServer` started on first claude-code prompt
    (de-duped concurrent first-callers via in-flight promise).
  - Per-op `setupInterventionForOp(opId, sessionId)`: registers an
    `AskUserBridge`, writes `os.tmpdir()/lobe-cc-mcp-<opId>.json` with
    `alwaysLoad: true` so CC eager-loads the tool (1-hop call, no
    ToolSearch detour — see LOBE-8725 spike), pumps `bridge.events()`
    into the existing `heteroAgentEvent` broadcast.
  - Cleanup paths: exit handler `await intervention.cleanup()` settles
    pending MCP handlers + unlinks the temp config; pre-spawn errors
    short-circuit the same cleanup so we don't leak bridges on
    `buildSpawnPlan` / trace-session failures.
  - `before-quit` stops the MCP server (in addition to killing CC
    processes).
  - New `@IpcMethod() submitIntervention({ operationId, toolCallId,
    result?, cancelled?, cancelReason? })` — renderer side will dispatch
    answers / cancellations through this in Step 4/5.
  - codex unchanged — bridge setup is gated on `agentType === 'claude-code'`.
- `src/services/electron/heterogeneousAgent.ts` — renderer-side proxy
  for `submitIntervention`.
- New `claudeCode.test.ts` covers the four driver-arg paths
  (`--mcp-config` presence, ordering vs `--resume`, AskUserQuestion stay
  disallowed). Existing 28 controller tests still pass.

What still doesn't run end-to-end
- The renderer `heteroExecutor` doesn't consume `agent_intervention_request`
  yet — events go through the broadcast but the chat store ignores them.
- No UI to render the intervention card or to call `submitIntervention`.
Both lands in Steps 4/5 next.

*  feat(hetero-agent): correlate intervention with tool message + renderer handler (LOBE-8725 step 3.5+4)

Bridge now uses the caller-supplied toolCallId (CC's `claudecode/toolUseId`
from MCP `_meta`) instead of a random UUID, so the
`agent_intervention_request` event references the same id as the existing
tool message on the renderer side.

Renderer-side `heteroExecutor` learns the new event:

- Added `persistInterventionRequest(...)` next to `persistToolResult` —
  stamps `pluginState.askUserQuestion` (apiName + identifier + questions
  parsed from `arguments` + deadline + status='pending' + toolCallId)
  onto the matching tool message via `messageService.updateToolMessage`.
- New branch in `handleStreamEvent` for `'agent_intervention_request'`:
  defers behind `persistQueue` (so it lands AFTER `persistToolBatch`
  populates `toolMsgIdByCallId`), then mirrors the same pluginState onto
  the in-memory message via `internal_dispatchMessage` so the UI lights
  up immediately — no fetchAndReplaceMessages round-trip needed.
- The eventual `tool_result` for the same toolCallId hits the existing
  `tool_result` branch unchanged: it overwrites `pluginState` with
  whatever the result carries (typically undefined for our MCP tool, so
  `pluginState.askUserQuestion` clears and the intervention UI yields to
  the regular Render).

Bridge tests cover the new contract:
- caller-supplied toolCallId becomes the wire correlation key
- duplicate-toolCallId pendings reject loudly so two-handler clobbers
  surface immediately

153 package tests + 1167 desktop main tests + 51 hetero executor tests
still green; type-check clean.

*  feat(claude-code): AskUserQuestion intervention render component (LOBE-8725 step 5)

Dedicated Render for the synthetic `askUserQuestion` apiName the adapter
rewrites the local MCP `mcp__lobe_cc__ask_user_question` tool to. Lives
under CC's render registry so the existing chat tool-detail flow picks
it up automatically — no changes to the conversation framework.

- New `AskUserQuestionItem` / `AskUserQuestionArgs` /
  `AskUserQuestionPluginState` types (mirrors CC's own
  AskUserQuestion schema verbatim).
- `ClaudeCodeApiName` gains an `AskUserQuestion = 'askUserQuestion'`
  member so the renders / inspectors / streamings registries can key
  off the same enum value.
- `client/Render/AskUserQuestion/index.tsx` is the component:
  - `pluginState.askUserQuestion?.status === 'pending'` → renders the
    questions form (Select for single-select, CheckboxGroup for
    multi-select), a 5-min countdown ticking once a second, Submit /
    Skip buttons. Reads `operationId` via `messageOperationMap` so we
    can route through `heterogeneousAgentService.submitIntervention`.
  - Otherwise → renders the questions as muted captions plus the
    final answer text from `content`. Surfaces a warning when the
    tool_result was an error (timeout / cancelled / session ended).
  - Submit button stays disabled until every question has a
    selection; Skip always enabled (sends `cancelled: true`).
- `ClaudeCodeRenders[ClaudeCodeApiName.AskUserQuestion]` registers
  the new component.

What this does NOT do
- Doesn't touch `BuiltinToolInterventions` — the form is rendered
  inside the regular tool body (Render slot), not the canonical
  intervention slot. Cleanest for now: the framework intervention
  flow assumes `submitToolInteraction` store actions, which would
  fight our IPC path. We can refactor onto that surface later if
  CC grows additional interactions (approval, file picker).
- Doesn't translate strings — i18n in a follow-up.

Type-check clean. Step 6 (real desktop e2e via CC) is next.

*  feat(claude-code): render AskUserQuestion form during pending state (LOBE-8725 step 5 follow-up)

Step 5 registered the Render component but stopped at the registry — the
chat tool-detail still returned the loading placeholder while
`isToolCalling` was true, so users only ever saw a spinner during the 5
min intervention window.

Detect `pluginState.askUserQuestion?.status === 'pending'` (only set on
CC + apiName=askUserQuestion tool messages) and route to the registered
builtin Render inline before the placeholder branch. Once the
intervention resolves, the eventual `tool_result` clears
`pluginState.askUserQuestion` and the regular Render takes over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(hetero-agent): wire regenerate / continue for hetero runtime (LOBE-8519 follow-up)

LOBE-8519 left two TODOs in `generationSlice` where hetero runtime
silently fell through to client mode — regenerate would secretly hit the
agent's underlying LLM, and continue would synthesize a fake "please
continue" turn that confuses CC / Codex.

- regenerateMessage: re-create the assistant row branched off the same
  user message, resolve resume sessionId (drop on cwd mismatch), then
  spawn a child `execHeterogeneousAgent` op so Stop only kills the
  executor, not the parent regenerate op. Mirrors sendMessage's hetero
  branch.
- continueGenerationMessage: hetero CLIs have no continue primitive —
  each prompt is a fresh user turn — so bail out instead of polluting
  the session.
- continueGenerationMessage: gateway mode now branches a server-side
  resume run instead of falling through to client.

Surfaced while testing CC AskUserQuestion end-to-end on the
LOBE-8725 branch (regenerating after an answered question went through
the wrong runtime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(local-testing): electron-dev.sh boots on macOS bash 3.2

Two bugs surfaced when invoking the local-testing helper from a fresh
session on macOS:

- `find_project_pids` / `do_stop` end with `grep -v '^$'` whose exit
  code propagates through `pipefail`. With `set -e`, an empty pid set
  silently kills the whole script — `do_start` reported success, no
  Electron, no error. Trail with `|| true`.
- `setsid` is GNU coreutils, not on macOS. Fall back to plain `bash -c`;
  process-tree teardown still works because `expand_descendants` walks
  the tree directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): per-session MCP transport for sequential ops (LOBE-8725)

`AskUserMcpServer` shared a single `StreamableHTTPServerTransport` across
every CC subprocess. The SDK transport latches `_initialized=true`
after the first `initialize`, so the second op's CC subprocess sees
`Invalid Request: Server already initialized` (400) and reports the
`lobe_cc` server as `failed`. From the model's POV the MCP tool is
absent — it falls back to ToolSearch, can't find anything, and
verbalizes the question instead.

Refactor to the canonical multi-tenant pattern: one transport + one
`McpServer` per session, looked up by the SDK-managed `mcp-session-id`
header. New transports are minted on the first POST without a session
id (must be an `initialize` request); subsequent requests route via
the stored map; `onsessionclosed` cleans up.

The first run of any process still works as before — this only matters
once a second op spins up. Added a 3-op sequential regression test
that fails on the old single-transport implementation and passes now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(claude-code): move AskUserQuestion onto canonical Intervention surface (LOBE-8725)

Step 5's first cut shoehorned the pending form into the Render slot and
drove submit/skip with a custom `pluginState.askUserQuestion.status`
field, which forced three layers of glue:

- `Tool/Detail` had to bypass the loading placeholder via an
  identifier+apiName hardcode so the form would surface during
  `isToolCalling`
- The executor had to `messageService.getMessages → replaceMessages`
  after `agent_intervention_request` to drag the freshly-created tool
  row into in-memory state (the framework's own `tool_end →
  fetchAndReplaceMessages` only fires after the user answers)
- The executor also had to `associateMessageWithOperation` for the tool
  row so the form could look up the running CC op for IPC

All three were patches around skipping the canonical surface. This
commit moves AskUserQuestion onto `pluginIntervention.status='pending'`
and the `BuiltinToolInterventions` registry, which the framework
already drives end-to-end:

- `packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion.tsx`
  — pure form, no IPC, no store reads. Resolves through the standard
  `onInteractionAction({type:'submit'|'skip'|'cancel'})` callback.
- `Render/AskUserQuestion` shrinks to the answered/aborted view only;
  the framework hides Render while pending, so no status switching.
- New `Inspector/AskUserQuestion` shows a compact "askUserQuestion · {header}"
  chip in the inline tool body, matching the rest of CC's tools.
- Registries: `ClaudeCodeInspectors`, `ClaudeCodeRenders`, and the new
  `ClaudeCodeInterventions` all key off `ClaudeCodeApiName.AskUserQuestion`;
  `BuiltinToolInterventions` gains a `[ClaudeCodeIdentifier]` entry.

Hetero needs a different action handler than `submitToolInteraction`
(which spawns `executeClientAgent` — wrong for a CC subprocess that's
already blocked on an MCP call). Two thin pieces wire that:

- `submitHeteroIntervention` (chat store) — sets
  `pluginIntervention` via `optimisticUpdateMessagePlugin` (which
  already syncs DB + in-memory + parent-assistant `tools[].intervention`
  in one shot), then forwards the answer through
  `heterogeneousAgentService.submitIntervention` IPC. Operation lookup
  walks the tool message's `parentId` to hit the assistant's
  `messageOperationMap` entry — drops the explicit
  `associateMessageWithOperation` call from the executor.
- `customInteractionHandlers.isHeteroInteractionIdentifier` flags
  `ClaudeCodeIdentifier`; `Tool/Detail/Intervention` short-circuits
  there before reaching the existing `submitToolInteraction` path.

Executor change collapses to one line:
`optimisticUpdateMessagePlugin(toolMsgId, { intervention: { status: 'pending' } })`.
The post-intervention refresh, the associate call, and the
`persistInterventionRequest` helper all go away.

Removed:
- `AskUserQuestionPluginState` type (custom field is gone)
- `Tool/Detail` `askUserPending` inline-render branch
- Executor `messageService.getMessages + replaceMessages` round-trip
- Executor `associateMessageWithOperation` for tool rows
- `persistInterventionRequest` helper

Verified end-to-end against a real CC subprocess on desktop:
- Inline body shows the new Inspector chip; pending form lives in the
  bottom InterventionBar (canonical surface)
- Submit ships answer through MCP, CC continues with structured result
- Skip flips status to `rejected`, framework's RejectedResponse
  shows "User skipped"; CC receives isError and falls back to text
- `mcp_servers.lobe_cc.status === 'connected'` on a 3rd sequential op
  (the per-session transport fix from the previous commit)
- `alwaysLoad: true` still produces 1-hop calls (no ToolSearch hop)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(claude-code): inline numbered option cards for AskUserQuestion intervention (LOBE-8725)

Select dropdown was the wrong primitive — it hides options behind an extra
click and doesn't read like a question to answer. CC's underlying tool is
1-4 questions × 2-4 options, so the whole option set always fits inline.

- Each option renders as a clickable card: numbered chip (1/2/3/4) +
  bold label + secondary description on a single row. Hover tints the
  background; selected state lights up `colorPrimary` on both the chip
  and the card outline so the pick is unmistakable at a glance.
- Multi-select (`q.multiSelect`) toggles instead of replacing, with a
  "(multi-select)" hint in the question header.
- Multi-question support gets a proper visual hierarchy: each question
  past the first sits below a dashed divider, headed by a `Q1/N` tag
  + the original `q.header` chip. The `Q*/N` lets the user track
  progress without counting.
- Inspector picks up the question count too: now shows
  "askUserQuestion · {first header} +N" when multiple are queued.

Verified end-to-end on desktop with a CC-driven 2-question prompt
(4-option + 3-option). Both selections feed back to CC as a single
"User answers" payload, CC echoes both picks in its continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(claude-code): tabbed multi-question + draft + timeout fallback for AskUserQuestion (LOBE-8725)

- Multi-question forms now use a top tab strip; single question renders inline.
- Picking a single-select option auto-advances to the next unanswered question.
- Drafts persist to tool message `pluginState.askUserDraft` so picks survive
  remount / HMR; new `setInterventionDraft` action on the chat store dispatches
  the pluginState patch.
- Timeout fallback: when the 5-min countdown expires, auto-submit option 1 for
  every unanswered question instead of letting the bridge time out into a
  cancelled isError — model gets a structured answer it can act on.
- Visual: selected option now uses filled `colorPrimaryBg` + right-aligned
  check icon; index chip stays neutral.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): synchronously unlink temp mcp.json on app quit (LOBE-8725)

The async exit-handler cleanup raced Electron's main-process teardown and
left `lobe-cc-mcp-<opId>.json` files in `os.tmpdir()` after every quit. Sync
unlink in the quit hook is the only reliable guarantee.

Also handle SIGTERM / SIGINT — `before-quit` only fires on user-driven Cmd+Q
or `app.quit()`, not on external kills (test harness, OS shutdown).

Verified by manual test: pending askUserQuestion forms now leave zero
residue after both Cmd+Q and SIGTERM paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(claude-code): persist structured AskUserQuestion answers + Q&A render (LOBE-8725)

Submit now writes the structured `{ questionText: pickedLabel(s) }` payload
to the tool message's `pluginState.askUserAnswers` (in-memory + DB merge), so
Render no longer has to scrape the bridge's prose `User answers:` content.

Render shows one Q&A block per question — header + question + a checkmark
card per picked option (multi-select fans out into multiple rows). Falls
back to a `—` placeholder when answers are missing (older messages or
skipped flows), and keeps the existing `pluginError` warning for cancel /
no-answer paths.

Also surfaces the answers in the Skill state inspector tab, which was
previously empty for completed askUserQuestion messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(hetero-agent): cover synchronous quit cleanup of AskUserQuestion temp configs (LOBE-8725)

Locks down the regression fixed in c0de0cdb7c — async exit-handler cleanup
losing to Electron's main-process teardown. Four cases: `before-quit`
(Cmd+Q / `app.quit()` path), `SIGTERM` (test harness / OS shutdown),
`SIGINT` (Ctrl-C), and idempotency (already-deleted temp file must not
throw on the second pass).

`process.on` and `process.exit` are stubbed in the signal-path tests so the
controller's listener attaches to a spy, not the test runner's process —
otherwise we'd leak a real SIGTERM listener every test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Neko 71ddedaa83 ️ perf(agent-signal,prompts,types,database,server): fixed many minor self-review issues, harden the structure, verified with eval (#14647) 2026-05-13 02:57:09 +08:00
Arvin Xu 60a127b1e5 💄 style(copyable-label): wrap long tool-call params instead of truncating (#14640)
* 💄 style(copyable-label): wrap long values instead of truncating

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(copyable-label): make wrap an opt-in via Descriptions prop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(descriptions): omit GridProps wrap to avoid type collision

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Arvin Xu b85a1ad851 💄 style: format tool execution time as Xmin Ys instead of X.Y min (#14641)
🐛 fix: format tool execution time as `Xmin Ys` instead of `X.Y min`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
Arvin Xu 7daed90d0e 🐛 fix(model-runtime): enrich stream parse errors with provider/model context (#14636)
*  feat(model-runtime): enrich stream parse errors with provider/model context

When the OpenAI / Anthropic SDK iterator throws (most often a JSON
SyntaxError on a malformed SSE chunk — e.g. an upstream response with an
illegal backslash escape), `convertIterableToStream` previously only
surfaced `message`/`name`/`stack`. Downstream error logs (agent-gateway
errors table) end up with just "Bad escaped character in JSON at
position 160050" and no way to correlate which provider/model produced
it or whether the same offset keeps recurring.

This change threads optional `{ provider, model }` context through
`convertIterableToStream` / `readableFromAsyncIterable` and enriches the
FIRST_CHUNK_ERROR payload with:

- `provider` / `model` so triage can group identical upstream failures
- `parsePosition` extracted from V8 JSON SyntaxError messages
- `causeName` / `causeMessage` when `error.cause` is set (many wrapped
  errors carry the actionable detail in `cause` and the bare triplet
  drops it)

Threaded through OpenAI/Responses/Anthropic stream handlers, which all
already receive `payload` containing provider/model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(model-runtime): walk error.cause for parsePosition + JSON-safe payload

Two review findings on #14636:

1. Wrapped SyntaxErrors lost their parsePosition. Provider SDKs commonly
   rethrow `JSON.parse` failures wrapped in their own error class
   (e.g. `APIError(cause: SyntaxError)`), so the outer `error.name` is
   no longer `'SyntaxError'` and the previous check skipped extraction
   for the exact case this enrichment was meant to diagnose. Now
   `extractParsePosition` walks both the outer error and any `Error`
   cause, and accepts any error whose message still carries the
   `"JSON at position N"` signature even if the SyntaxError name was
   lost in wrapping.

2. Cause cloning could blow up the entire diagnostic path.
   `structuredClone` succeeds on values that `JSON.stringify` later
   throws on (BigInt, circular refs), so a non-Error cause carrying
   either would surface as `payload.cause = clonedObject`, then the
   outer `JSON.stringify(payload)` would throw inside the catch handler,
   and the FIRST_CHUNK_ERROR chunk never gets emitted. Replaced with
   `safeJsonStringify` (BigInt → string, cycles → `[Circular]`) and
   route the cause object through `toJsonSafe` so the returned shape is
   always plain JSON.

Added tests for both: a wrapped APIError(cause: SyntaxError) yields
parsePosition, and a cause containing both BigInt and a circular ref
still emits a parseable error chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
Arvin Xu 0babdcfc00 🐛 fix(home): strip markdown links from daily-brief input placeholder (#14635)
The daily-brief hint will start carrying `[name](url)` markdown links so
the AI can resolve referenced entities when the user submits via the
hint. The placeholder layer is the only consumer that wants the visible
label without the link syntax — extract a small `stripMarkdownLinks`
util and apply it at `InputArea/index.tsx` only. `useSend` continues to
forward the raw hint, so the agent still receives the link in the
outgoing message.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
YuTengjing d445a89c85 🐛 fix: consume visual content parts in server runtime (#14637) 2026-05-13 02:57:08 +08:00
Arvin Xu 3c8101128e feat(bot): gate device tools by sender identity (#14634)
*  feat(bot): gate device tools by sender identity (LOBE-8715)

External users who @-mentioned a bot ran the agent as the bot owner and
could call LocalSystem / RemoteDevice tools — a confused-deputy hole that
let any group member indirectly read/write the owner's machine.

- `ChatTopicBotContext` carries `senderExternalUserId` + `isOwner`
- `BotMessageRouter` / `MessengerRouter` compute `isOwner` at the entry
  point (fail-closed when `settings.userId` is missing)
- `resolveDeviceAccessPolicy` maps sender identity to
  `{ canUseDevice, reason }`; trusted-list branch is reserved for future
  work without engine changes
- `AgentToolsEngine` gates `LocalSystem` + `RemoteDevice` on `canUseDevice`
- `RemoteDeviceManifest.systemRole` is no longer injected on
  external-sender turns — closes the device-list information leak
- Per-call audit log (`lobe-server:agent-device-tool-audit`) at the
  dispatch site records sender, isOwner, reason, identifier, apiName

Fixes LOBE-8715

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🚨 chore(bot): replace `any` on botContext / botPlatformContext with concrete types

Picks up the existing `BotPlatformContext` (`@lobechat/context-engine`)
and `ChatTopicBotContext` (`@lobechat/types`) — both already exported —
instead of the inherited `any` placeholders on:

- `OperationCreationParams.{botContext, botPlatformContext, deviceAccessPolicy}`
- `InternalExecAgentParams.botPlatformContext`
- `RuntimeExecutorContext.botPlatformContext`

`deviceAccessPolicy.reason` is now `DeviceAccessReason` instead of `string`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔒 fix(bot): clear activeDeviceId when canUseDevice=false (LOBE-8715)

The previous patch gated `LocalSystemManifest` in the engine's enabledToolIds,
but `buildStepToolDelta` re-injects local-system from `state.metadata.activeDeviceId`
on every step regardless of whether the engine excluded it. Auto-activation
in `aiAgent.execAgent` populated `activeDeviceId` whenever
`(discordContext || botContext) && onlineDevices.length === 1`, so an
external bot sender with one device online could still get local-system
tools against the owner's device.

- `aiAgent/index.ts`: skip `activeDeviceId` derivation entirely when
  `canUseDevice` is false. `deviceSystemInfo` short-circuits naturally on
  `if (activeDeviceId) {...}`, so no extra change needed there.
- `RuntimeExecutors.ts`: belt-and-suspenders — if
  `state.metadata.deviceAccessPolicy.canUseDevice` is false, swallow
  `activeDeviceId` before passing to `buildStepToolDelta`, so a future
  plumbing bug at the source can't reopen the bypass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔒 feat(bot): allow device tools on personal-scope platforms (WeChat) (LOBE-8715)

Not every bot platform can identify an owner. WeChat's LobeHub integration
encodes every inbound thread as 1:1 (`packages/chat-adapter-wechat/src/adapter.ts:465`)
and its settings schema has no `userId` field, so `isOwner` is structurally
false on every WeChat turn. The previous policy denied every WeChat call
with `bot-owner-not-configured` — fail-closed but unusable.

This commit treats platforms whose integration is structurally personal-
scope as trusted. WeChat is the only member today; LINE is intentionally
excluded because its adapter handles group/room threads even though its
schema also lacks `userId` — those must be fixed at the schema layer
before being whitelisted.

- New `bot-personal-platform` reason in `DeviceAccessReason`
- `PERSONAL_SCOPE_BOT_PLATFORMS = new Set(['wechat'])`
- Personal-scope check sits AFTER `isOwner` so a future WeChat schema
  with a `userId` field still resolves as the more specific `bot-owner`
- Tests: WeChat without isOwner → allow; WeChat with isOwner=true → still
  `bot-owner` (more specific wins); regression guard ensuring Discord /
  Slack / Telegram / Feishu / Lark / QQ / LINE keep going through the
  standard isOwner gate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(engine): opt existing device gate tests into canUseDevice=true (LOBE-8715)

The `LocalSystem` / `RemoteDevice` enable rules now short-circuit on
`canUseDevice` (default `false`), so tests that exercise the
engine-internal gates (`runtimeMode`, `deviceContext`, `clientRuntime`)
must explicitly pass `canUseDevice: true` — otherwise they assert the
right behavior for the wrong reason or fail outright (e.g. the desktop
RemoteDevice-suppression case the reviewer flagged).

- All `LocalSystem` / `RemoteDevice` / `LocalSystem + RemoteDevice` /
  `clientRuntime === "desktop" (Phase 6.4)` blocks now set
  `canUseDevice: true`.
- The "disable RemoteDevice in bot conversations" test was repurposed:
  the dropped `!isBotConversation` clause is now subsumed by `canUseDevice`,
  so for a trusted bot caller (canUseDevice=true) RemoteDevice DOES surface.
  The original intent — block when caller is untrusted — is captured in
  the new `canUseDevice gate` block.
- New `canUseDevice gate` describe block asserts:
    1. `canUseDevice=false` blocks LocalSystem even on a desktop caller
    2. `canUseDevice=false` blocks RemoteDevice with proxy configured
    3. Omitting `canUseDevice` → fail-closed default (deny)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(execAgent): set isOwner=true on device auto-activation tests (LOBE-8715)

These pre-existing tests model an owner using the bot through Discord and
assert that `activeDeviceId` auto-populates when one device is online.
After LOBE-8715, `activeDeviceId` is gated on `canUseDevice` from
`resolveDeviceAccessPolicy`, so a `botContext` without `isOwner: true`
resolves to `bot-external-sender` → `canUseDevice=false` →
`activeDeviceId=undefined`.

Filling out the `botContext` mocks with `isOwner: true` (plus the other
required fields the type now demands) preserves the tests' original
intent while exercising the new gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
YuTengjing 9982de3a5c 🐛 fix: store onboarding interests as keys (#14624) 2026-05-13 02:57:08 +08:00
Arvin Xu 7f6fdd7c14 🔥 chore(web-crawler): remove WeChat URL rules (#14633)
Drop the `weixin.sogou.com` and `mp.weixin.qq.com` rules from the crawler
URL ruleset since they are no longer needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
LobeHub Bot d13f2e3ad8 🌐 chore: translate non-English strings to English in apps/cli, apps/device-gateway, and apps/desktop scripts (#14626)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
LiJian 7675bd9fb5 🐛 fix(hetero-agent): sync new-step assistant across replicas (#14631)
* 🐛 fix(hetero-agent): sync new-step assistant across replicas

* 🐛 fix(hetero-agent): tighten new-step assistant fallback

* fix: slove the test
2026-05-13 02:57:08 +08:00
LiJian 457d112a74 🐛 fix: remove the old cron job from lobehub (#14630)
* fix: remove the old cron job from lobehub

* fix: add some ts back
2026-05-13 02:57:08 +08:00
LiJian 6595961e5a 🐛 fix: refresh content baseline from DB on every ingest call (#14603)
* 🐛 fix: refresh content baseline from DB on every ingest call

Vercel serverless routes consecutive batches to different Lambda
instances. A warm replica's in-memory `accumulatedContent` only
reflects batches it processed; it has no visibility into batches
handled by other replicas.

The failure pattern (worst when a repo is selected, since CC makes
tool calls early):

1. Lambda A — batch 1 (text "你好!...") → flushBatchContent writes
2. Lambda B — batch 2 (text "...任务。") → restores from DB, appends,
   writes longer text to DB
3. Lambda A — batch 3 (tools_calling only, warm state) → its stale
   `accumulatedContent` = batch-1 text → persistMainToolBatch Phase 1
   writes `{ tools, content: stale-short-text }` → OVERWRITES the
   correct longer DB value → content truncated at "你"

Fix: re-read the current assistant message from DB at the start of
every `ingest()` call. Since `flushBatchContent` writes at the end of
every batch, DB is authoritative. The refresh gives each Lambda the
latest flushed baseline, so new text in the current batch extends
the correct full string.

Cost: one extra `findById` round-trip per warm ingest call.

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

*  feat: auto-inject GitHub OAuth token into CC sandbox

Previously the GitHub token was only resolved when repos were selected
AND GITHUB_CRED_KEY was explicitly configured in the agent config —
so CC running without pre-selected repos had no GitHub access and had
to ask the user for a PAT manually.

Changes:
- aiAgent/index.ts: always try to resolve the token using key 'github'
  (standard LobeHub OAuth connector default); GITHUB_CRED_KEY still
  overrides. No longer guarded behind topicRepos.length > 0.
- sandboxRunner.ts: new buildCredsSetupScript() runs before CC starts:
    mkdir -p ~/.creds
    printf 'GITHUB_ACCESS_TOKEN=%s\n' <token> > ~/.creds/env
    gh auth login --hostname github.com --with-token
  Writes ~/.creds/env in the same format as injectCredsToSandbox(["github"])
  so CC can source it in sub-shells. Creds step runs before repo clone step.
- cloudHeteroContext.ts: system prompt now tells CC that GITHUB_TOKEN is
  set, gh CLI is pre-authenticated, and ~/.creds/env has GITHUB_ACCESS_TOKEN
  with the source/auth recipe for sub-shell usage.

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

* 🐛 fix: adopt max-length content on DB refresh to guard flushBatch retry

The unconditional DB overwrite in ingest() broke the retry contract:
if flushBatchContent threw after events were already marked in
processedKeys, a retry on the same warm instance would read the stale
(shorter) DB value and wipe the in-memory chunks — which processedKeys
would then skip, losing them permanently.

Fix: only adopt the DB value when it is LONGER than in-memory.
This preserves both behaviours:
- Multi-replica stale (the original fix): DB has more content from
  another replica → dbContent.length > in-memory → adopt DB. ✓
- flushBatchContent retry on same Lambda: DB still has the old shorter
  value, in-memory has the correct accumulation → keep in-memory. ✓

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
Arvin Xu ae8f9cfb27 🐛 fix(hetero-agent): disable Claude Code AskUserQuestion to avoid auto-decline (#14629)
* 🐛 fix(hetero-agent): disable Claude Code AskUserQuestion to avoid auto-decline

CC's built-in AskUserQuestion self-injects an `is_error: "Answer questions?"`
tool_result inside the CLI in `-p` non-interactive mode before the host can
surface the questions, so the model falls back to plain-text prompting after
a wasted round-trip. Add `--disallowedTools AskUserQuestion` to both spawn
sites (desktop driver + lh hetero exec) so the model goes straight to text.

To be revisited once a local MCP-backed replacement is wired to LobeHub's
intervention UI.

* ♻️ refactor(hetero-agent): share CC base args, opt-in partial deltas

- Promote CLAUDE_CODE_BASE_ARGS in `@lobechat/heterogeneous-agents/spawn` to
  the canonical source of truth for invariant CC CLI flags (`-p`, stream-json
  IO, `--verbose`, `--disallowedTools AskUserQuestion`); export it so the
  desktop driver can compose on top instead of duplicating.
- Pull `--include-partial-messages` out of the base. It's now a
  `SpawnAgentOptions.includePartialMessages` flag, off by default so
  `lh hetero exec` standalone/sandbox runs don't pay for delta noise they
  don't render. The desktop driver opts in (chat bubble streams live).
- Permission mode stays caller-specific: desktop hardcodes bypassPermissions
  (always user-mode), the package keeps its root-vs-user branch for cloud
  sandbox.

* 🎨 style(hetero-agent): pass spawn-args builders an options object

Positional list grew to four args with mixed types — switch to a single
`BuildSpawnArgsParams` object so call sites read by field name and adding
future per-agent flags doesn't push every other caller around.
2026-05-13 02:57:08 +08:00
Arvin Xu 96165e453a 🐛 fix(local-system): guard readFile against binary blobs and oversized output (#14602)
* 🐛 fix(local-system): guard readFile against binary blobs and oversized output

Previously `lobe-local-system.readFile` would happily decode any extension
as UTF-8 and return the entire content. Reading a 27KB base64-encoded git
bundle blew up the next LLM call to 3.28M tokens / 416s and triggered a
DB rollback. The default 200-line cap was bypassed because base64 was a
single very long line.

Add four layers of protection in `readLocalFile`:
- Hard-reject extensions outside the text-readable + special-parser
  whitelist with a structured error pointing the agent at runCommand.
- Sniff the first 8KB and refuse files that look binary (null bytes or
  >30% non-printable chars).
- 10MB hard size cap before the file is read into memory.
- Cap each returned line at 8K chars and total output at 500K chars,
  with `truncated` / `linesTruncated` flags surfaced in the result.

Refs LOBE-8703.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(file-loaders): preserve UTF-16 text files without a BOM in binary sniffer

The binary sniffer rejected UTF-16LE/BE files that lacked a BOM because
their alternating 0x00 bytes tripped the null-byte heuristic. `TextLoader`
already has a `detectUtf16NoBom` heuristic for these Windows-style exports;
extract it to a shared `detectUtf16` util and run it in the sniffer before
the null-byte check, decoding with the matching variant for the printable
ratio test instead of declaring the file binary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(local-system): render WriteFile new files as a unified diff

Switch the WriteFile render from a syntax-highlighted preview to a
synthesized "new file" unified diff via PatchDiff, matching the
EditLocalFile visual. Markdown files keep their rendered preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(local-system): exercise readFile / readFiles end-to-end

The previous LocalFileCtr.readFile / readFiles tests deep-mocked
node:fs/promises and @lobechat/file-loaders. Since the controller is a
thin pass-through to readLocalFile, the assertions ended up testing
shell internals (already covered in packages/local-file-shell), and
broke as soon as readLocalFile gained new pre-flight checks.

Move them into a sibling LocalFileCtr.readFile.test.ts that runs
against a real tmpdir + real file-loaders, so adding more upstream
guards no longer requires touching this suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
YuTengjing 521566bdb7 feat: add user activity business hook (#14601) 2026-05-13 02:57:08 +08:00
Hardy ab7b9e3e69 ♻️ refactor(siliconcloud): sync models with API, fix duplicates, adjust reasoning params (#14464)
* ♻️ refactor(siliconcloud): sync models with API, fix duplicates, adjust reasoning params

* 🐛 fix(siliconcloud): fix GLM-4.7 checkModel casing to match model ID
2026-05-13 02:57:08 +08:00
AmAzing- fa55b3fb25 🌐 i18n: update banner copy translations (#14623) 2026-05-13 02:57:08 +08:00
AmAzing- e300766046 💬 i18n: remove trailing punctuation from banner titles (#14622) 2026-05-13 02:57:08 +08:00
YuTengjing 9b032f0773 feat: add Gemini 3.1 Flash-Lite provider cards (#14604) 2026-05-13 02:57:08 +08:00
YuTengjing 629213189b ♻️ refactor: remove model extend param options (#14607) 2026-05-13 02:57:08 +08:00
René Wang f38f0c258b 📝 docs: add intro and screenshot to task scheduler changelog (#14585) 2026-05-13 02:57:07 +08:00
Neko 38b793f41b 🐛 fix(database,utils,userMemories): should perfer to use paradedb.match(...) instead of hardcoded normalizer (#14590) 2026-05-13 02:57:07 +08:00
Arvin Xu 11ec59b8c8 🐛 fix(database): attach error listeners to Neon/Node pools to prevent Lambda crash (#14606)
* 🐛 fix(database): attach error listeners to Neon/Node pools to prevent Lambda crash

NeonPool (and NodePool) inherit pg.Pool semantics: when a backend connection
drops on an idle client the pool emits 'error'. With no listener Node
escalates that into uncaughtException — on Vercel this killed the entire
Lambda process (exit 129) and produced a 1805-crash avalanche in 5 minutes,
spiking Neon connection count from 30 to 330+ as half-closed sockets
accumulated (LOBE-8704).

Primary fix: attach `.on('error', ...)` to both pool variants in
`packages/database/src/core/web-server.ts` so the error is logged but
swallowed; the pool recovers on its own per pg docs.

Defense in depth: register `uncaughtException` / `unhandledRejection`
handlers in `instrumentation.ts` (gated to nodejs runtime) so any future
unhandled error doesn't take down the process either.

Refs: https://node-postgres.com/apis/pool#error

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔧 chore: drop process-wide uncaughtException handler

Per review on #14606: the catch-all listener in instrumentation.ts swallowed
every uncaughtException / unhandledRejection — not just NeonPool errors —
leaving the process in an undefined state instead of letting the platform
restart it, and would mask future production bugs.

LOBE-8704 is fully addressed by the targeted pool listeners in
packages/database/src/core/web-server.ts; the broad backstop is unnecessary
and unsafe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
sxjeru 867e22a90e 💄 style: Add new DeepSeek-V4 models (#14110)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-13 02:57:07 +08:00
Arvin Xu 4bfd434552 🐛 fix: gateway client-tool pluginState + drop redundant Exit code: 0 tail (#14596)
* 🐛 fix(agent-runtime): forward pluginState through gateway client tool result

Gateway-mode client tool results lost the `state` field at three points:
the toolResult Zod schema didn't declare it (silently stripped by safeParse),
the ToolResultPayload interface didn't carry it, and projectToExecutionResult
didn't return it. As a result the "技能状态" tab was always empty for tools
dispatched via Agent Gateway, even though clients send `state` correctly and
non-gateway paths persist it as `pluginState`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(prompts): suppress redundant `Exit code: 0` tail in command result

For successful runs, "Command completed successfully." already conveys
the same signal — appending "Exit code: 0" was just noise the LLM had
to skim past. Non-zero exit codes (130 SIGINT, 137 OOM, etc.) keep the
line so the diagnostic information remains available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(prompts): treat non-zero exit code as command failure in result header

`success` is the envelope ("the service responded") and `exitCode` is the
command's own status — they're independent. With `success: true` +
`exitCode: 137` the prior format rendered "Command completed successfully."
on top of a SIGKILL/OOM, lying to the LLM.

Now the header is derived from both: any non-zero exit folds the message
into the failure branch as "Command failed with exit code N[: error]".
The trailing "Exit code: N" line is gone — the same info now lives in the
header, so success rendering is also free of the redundant zero tail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
sxjeru 307cd8e523 🐛 fix(gemini): handle zero cachedContentTokenCount in usage conversion (#14567)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-13 02:57:07 +08:00
Arvin Xu a2750098f4 💄 style(topic): add copy session ID to topic dropdown menu (#14595)
 feat(topic): add copy session ID to topic dropdown menu

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
Arvin Xu 12e37f1e46 feat: home daily brief with linkable welcome + paired input hint (#14589)
*  feat: home daily brief with linkable welcome + paired input hint

Add a per-user "daily brief" surface to the home page. A cron-driven
backend (in the cloud repo) writes paired { welcome, hint } entries
into Redis under `aiGeneration:home_brief:{userId}`. This change exposes
that data through:

- `RedisKeys.aiGeneration.homeBrief` key builder
- `home.getDailyBrief` lambda router query that reads the cached payload
- `homeService.getDailyBrief` client and `useHomeDailyBrief` hook with
  shared rotating index via `useSyncExternalStore`
- `WelcomeText` runs a custom typewriter (supports real `\n` line breaks
  and parses inline `[label](url)` markdown links so cached entity
  references become clickable; falls back to the i18n welcome list)
- `InputArea` shows the matching hint as the chat input placeholder

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: extract daily-brief Redis read into HomeService

Mirrors the AgentService pattern: the lambda home router was reaching
into Redis directly, which mixed I/O concerns with the routing layer.
Move the read into a dedicated `HomeService` so future home-page reads
have a clear home and the router stays thin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix: keep WelcomeText typewriter index in sync with shared store

Before: DailyTypewriter held its own `sentenceIndex` state, separate
from the module-level `currentIndex` in `useHomeDailyBrief`. After
the home page rotated past the first pair, navigating away and back
remounted the typewriter and reset its local index to 0 — but the
external index stayed where it was. InputArea read the hint at the
stale external index while WelcomeText restarted at pair 0, breaking
the welcome / hint pairing.

Make the typewriter fully controlled: drop the local `sentenceIndex`,
expose `currentIndex` from `useHomeDailyBrief`, and pass it as a prop.
On `pause`, the typewriter just calls `onSentenceComplete` — the
parent flips the shared index, the new prop flows back, the reset
effect re-arms typing for the new sentence. Single source of truth,
remount-safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(redis): factor JSON cache reads into getJSONFromRedis util

Three call sites were inlining the same "fetch + null-check + JSON.parse
+ try/catch" recipe against a scoped Redis client:

- AgentService.getAgentWelcomeFromRedis
- HomeService.readDailyBriefFromRedis (new)

Move the recipe into a small `getJSONFromRedis<T>` helper next to the
other Redis utilities and have both services delegate to it. Caller
keeps responsibility for resolving the right scoped client (we don't
want to hide the prefix selection inside the helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): use live editor content for Enter-to-send guard

When typing into the home input and pressing Enter immediately, the
empty-message guard sometimes wrongly bailed out. The cause: the guard
read the cached `inputMessage` in `useChatStore`, which is populated by
the editor's async `onMarkdownContentChange`. Lexical commits its
update on a microtask after each keystroke, so a fast type-then-Enter
fires the send path before the cache catches up.

`SendButtonHandler` already passes `getMarkdownContent` through — read
it instead, falling back to the cached value if the handler is invoked
without it. Also propagate the live message into all `inputActiveMode`
branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(home): accept daily-brief hint as the message on empty Enter

Press Enter on the empty home input → send the currently displayed
daily-brief hint as the message (smart-compose / Tab-to-accept style).
Trims the cosmetic trailing ellipsis and rotates the carousel so the
next press picks up a different pair.

Falls through to the previous "no content, skip" path when there's
neither a typed message nor a hint to use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): scope daily-brief SWR key + rotation index by userId

The SWR key was a constant string, so an account switch within the same
SPA session — sign out + sign in as another user, or a multi-account
swap that keeps `isSignedIn` true — could surface the previous user's
cached pairs from the same slot. The keyspace in Redis is per-user,
so the served data leaks personalization.

Include the resolved userId in the SWR key, and reset the module-level
rotation index on user change so the new account starts from pair 0
rather than inheriting a stale offset (which could also point past the
end of a smaller pairs list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
LiJian 09c66ffb4c 🐛 fix: first inject the cloudecc runtime session should use the existingStatus (#14592)
* 🐛 fix: skip reconnect when gateway action already established a connection

Race condition on new-topic first message:
1. switchTopic loads runningOperation → useGatewayReconnect fires
2. executeGatewayAgent calls connectToGateway (status: connecting)
3. reconnectToGatewayOperation overwrites with resumeOnConnect:true
4. Gateway sees resume on a brand-new session → no events → stuck

Second message works because the client store's runningOperation is
stale (from the first op), so SWR deduplications and no reconnect fires.

Fix: bail out of reconnectToGatewayOperation if gatewayConnections
already shows connecting/connected for that operationId.

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

* 🐛 fix: always pass --cwd /workspace for cloud CC to ensure session resume

CC stores session files at ~/.claude/projects/<encoded-cwd>/.
Without an explicit --cwd the actual working directory can differ
between sandbox invocations, so --resume <heteroSessionId> fails
to locate the previous session files even though the container is
persistent and the ID is correctly stored in topic.metadata.

Default cwd to /workspace for cloud runs (desktop keeps its own
explicit path), guaranteeing a stable session-file location across
page reloads within the same sandbox lifecycle.

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

* 🐛 fix: extend reconnect guard to cover all in-flight connection statuses

The previous guard only skipped reconnect for 'connecting'/'connected'
but the connection can already be in 'authenticating' or 'reconnecting'
by the time useGatewayReconnect fires, leaving the race window open.

Flip the condition: skip for any status that is not 'disconnected'.

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

* 🐛 fix: restore cold replica state in HeterogeneousPersistenceHandler

Vercel serverless functions are stateless per-request, so `operationStates`
is empty on every `heteroIngest` call. loadOrCreateState always cold-creates.

#14539 fixed `toolMsgIdByCallId` restoration but left `accumulatedContent`,
`toolState.payloads`, and `toolState.persistedIds` empty on cold load,
causing two bugs:

- Content truncation: cold instance starts with `accumulatedContent=''`,
  accumulates only the current batch's text, then writes that shorter string
  on the next step boundary or terminal — overwriting the longer content the
  previous write had already stored in DB.

- Tool duplication / tools[] overwrite: `persistedIds={}` on cold load
  means every `tools_calling` event re-creates already-persisted tool
  messages, and `payloads=[]` means phase 1/3 writes only the current
  batch's tools, wiping previous tools from `assistant.tools[]`.

Fix: in `loadOrCreateState`, fetch the current assistant message and restore
`accumulatedContent`, `accumulatedReasoning`, `toolState.payloads`, and
`toolState.persistedIds` from it. Cold load is now equivalent to warm load.

Also adds two regression tests covering the cold-replica scenarios.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
Arvin Xu 909b1ec461 💄 style: use visible divider between queued messages (#14593)
💄 style(QueueTray): use visible divider color between queued messages

The previous `colorBorderSecondary` rendered the divider effectively
invisible on the elevated dark surface. Switch to `colorFillTertiary`
so stacked queued messages have a perceptible separator.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
Rdmclin2 8274be0d1d 🐛 fix: slack connect error & slash commands (#14591)
* feat: displayToolCalls default undefined

* chore: restrict billboard to home page

* fix: add slack bot scope

* fix: show billboard in home nav
2026-05-13 02:57:07 +08:00
Neko b7a50206bf feat(agent-signal,prompts,database): self-review now proposal actions to briefs, and automatically execute actions (#14583) 2026-05-13 02:57:07 +08:00
Innei 5c1113031d 💄 style(intervention): polish confirmation bar layout (#14587) 2026-05-13 02:57:07 +08:00
AmAzing- fa17c75f90 chore: Refine homepage banner copy for channels and skills (#14588) 2026-05-13 02:57:07 +08:00
AmAzing- 0c659dbe22 🛠️ fix: unify SKILL.md frontmatter parsing and edit validation in agent documents (#14566) 2026-05-13 02:57:07 +08:00
LiJian d2c379c78d feat: add signOperationJwt with 4h expiry for hetero-agent operations (#14586)
*  feat: add signOperationJwt with 4h expiry for hetero-agent operations

- Add `signOperationJwt(userId)` to internalJwt.ts with 4h expiry and
  `purpose: 'hetero-operation'`, so Claude Code / Codex tasks running
  beyond 5 minutes no longer hit 401 on heteroIngest / heteroFinish
- Update `execAgent` hetero path to use `signOperationJwt` instead of
  `signUserJWT`; gatewayToken continues to use 5m `signUserJWT`
- Add unit tests in `__tests__/internalJwt.test.ts` with correct mocks
  for `jose` (SignJWT class + importJWK) and `authEnv`, covering all
  three signing functions and the expiry difference assertion

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

* 🔒 security: restrict hetero-operation JWT scope to heteroIngest/heteroFinish

A leaked 4-hour sandbox LOBEHUB_JWT must not be replayable against any
other authenticated lambda route.

- Forward `purpose` claim from JWT payload through validateOIDCJWT →
  tokenData → oidcAuth context so middlewares can inspect it
- oidcAuth: reject tokens with purpose 'hetero-operation' — they cannot
  reach any normal authedProcedure route
- New heteroOperationAuth middleware: exclusively accepts
  purpose 'hetero-operation' tokens, rejects all others
- Export heteroAuthedProcedure (baseProcedure + heteroOperationAuth +
  userAuth) from trpc/lambda/index.ts
- heteroIngest / heteroFinish now use heteroAgentProcedure built on
  heteroAuthedProcedure + serverDatabase + HeterogeneousAgentService
- Tests: heteroOperationAuth (4), oidcAuth (4), update heteroIngest
  test caller to supply purpose:'hetero-operation' context (23 total)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
Innei d73de25623 💄 style(settings): remove image avatar from lab input markdown rendering item (#14582) 2026-05-13 02:57:07 +08:00
YuTengjing a02ecbc40d 🐛 fix: polish task agent manager (#14569) 2026-05-13 02:57:07 +08:00
AmAzing- f1f2e58e01 feat: migrate Notion to LobeHub Market (#14578)
Migrate Notion to LobeHub Market
2026-05-13 02:57:06 +08:00
Arvin Xu 5f8ec8bbfb 🐛 fix(agent-runtime): recover malformed tool_call names instead of finishing silently (#14577)
* 🐛 fix(agent-runtime): recover malformed tool_call names instead of finishing silently

When an LLM emits tool_call names without the `____` separator (e.g. `activateTools`
instead of `lobe-activator____activateTools`), the resolver dropped them silently and
the harness finished with "completed without tool calls" — empty assistant bubble,
no error in dashboards.

Three layers of defense:

- Resolver fallback: when the bare name uniquely matches an API across known
  manifests, recover the identifier; ambiguous matches still drop to avoid
  false binding.
- StreamingHandler logs unresolved tool_call names so the silent-drop path is
  observable in debug output.
- GeneralChatAgent surfaces the unresolvable count and names in reasonDetail
  so dashboards can distinguish this from a genuine no-tool completion.

Fixes LOBE-8696

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-runtime): restrict bare-name fallback to tools offered this turn

Address review feedback on the LOBE-8696 resolver fallback. The
manifests map passed to ToolNameResolver.resolve is broader than the
tools actually sent to the LLM (the client builds it from every
installed plugin and every builtin; the server can preserve manifests
even after a step deactivates a tool). Without a turn-scope
restriction:

- A model returning a malformed bare name could resolve to a tool that
  was not enabled for this turn.
- A disabled duplicate API name could shadow the enabled call and make
  it look ambiguous, dropping a valid call.

Pipe an `offeredToolNames` list (the names actually sent in this LLM
payload) into resolve(): when set, the missing-prefix fallback only
considers manifests whose generated tool name appears in the list.

- ToolNameResolver.resolve gains an optional `offeredToolNames` param.
- internal_transformToolCalls forwards the list through.
- createAgentExecutors builds resolvedAgentConfig before the
  StreamingHandler so the closure can bind the offered names — same
  list that gets sent to the model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:06 +08:00
LiJian 7792f63453 feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context (#14568)
*  feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context

- Add CloudRepoSwitcher component (web-only multi-select repo picker)
  - Pre-topic selections buffered in module singleton (pendingTopicRepos)
  - Consumed by gateway.ts at topic creation time via appContext.initialTopicMetadata
  - Eliminates race condition where updateTopicMetadata dropped silently
- Extend ChatTopicMetadata with repos[] field for multi-repo binding
- Add initialTopicMetadata to ExecAgentAppContext so repos are written to
  topic metadata at creation time (server-side, zero race condition)
- Extend ExecAgentSchema Zod schema with initialTopicMetadata
- Inject GITHUB_TOKEN env var into sandbox so CC can use git/gh CLI
- Build cloudHeteroContext with GitHub auth section when token is available
- Add workingDirectory selector for web (repos[0] fallback)
- Add refreshTopic call in gateway path after new topic creation
- Add CloudHeterogeneousConfig profile editor for GITHUB_REPOS / GITHUB_CRED_KEY
- Extend sandboxRunner with repo clone setup script and systemContext support

* 🐛 fix: add open-source stub for pendingTopicRepos to fix Vite build

* ♻️ refactor: move pendingTopicRepos real impl into submodule, remove cloud override

* 🐛 fix: consume pendingTopicRepos only after topic creation succeeds

* 🐛 fix: add missing getPendingTopicRepos import in gateway

* 🔒 fix: address security and dead-code issues from PR review

- sandboxRunner: sanitize repo dir name to prevent shell injection
- sandboxRunner: use git insteadOf (-c flag) so token is never stored in .git/config
- cloudHeteroContext: fix return type from string|undefined to string (dead branch)
- CloudRepoSwitcher: remove unreachable empty-list branch in popover content

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

* 💬 i18n: add claude setup-token hint to token description

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

* 🐛 fix: remove incorrect web hetero→gateway forced routing in agentDispatcher

On web, heterogeneousProvider is ignored — routing falls through to isGatewayMode.
Cloud CC only runs when gateway mode is enabled; gateway.ts handles sandbox
spawning when it detects a hetero provider.

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

* 🐛 fix: restore web hetero→gateway routing; update stale test

On web, a configured heterogeneousProvider always routes to gateway —
the cloud sandbox is the only execution environment regardless of
isGatewayMode. The test assumed the pre-cloud-CC world where web
ignored hetero providers entirely.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:06 +08:00
Innei 2959ec3883 📝 docs(version-release): enforce git-derived PR refs and metrics (#14575)
* 📝 docs(version-release): enforce git-derived PR refs and metrics

Add the skill's first-class hard rules for computing release-note inputs
from git instead of memory: latest-tag base via `git describe`, PR refs
from commit subjects, metric counts from `wc -l`, handle resolution via
`gh pr view`, and a pre-publish `comm -23` diff that must be empty.
Also adds @cy948 to the team roster and notes Tsuki / René Wang's
commit-author aliases so contributor classification stops drifting.

* ♻️ refactor(version-release): split skill into router + per-flow references

SKILL.md was 426 lines covering three distinct flows. Split it so each
flow lives next to its own checklist:

- reference/minor-release.md — minor workflow (lifted from SKILL.md)
- reference/patch-release-scenarios.md — patch flows (existing)
- reference/release-notes-style.md — long-form changelog standard,
  template, and Computing Inputs hard rules (lifted from SKILL.md)

SKILL.md now reads as a router (~100 lines) with shared CI trigger
rules, post-release automation, precheck, and hard rules. Cross-links
between references replace the previous in-file jumps. Also fixes a
prettier-mangled redirect (`< some-pr-by-them >`) by using a `$PR`
variable instead of an angle-bracket placeholder.

* 📝 docs(version-release): add Hotfix and DB Migration variants to release-notes-style

The Canonical Structure was implicitly long-form (Minor / Weekly), and
hotfix authors had to read `changelog-example/hotfix.md` to learn it
existed. Make the divergence explicit:

- New § Variants for Shorter Releases describes Hotfix structure
  (Scope / What's Fixed / Upgrade / Owner) and DB Migration structure
  (Migration overview / Operator impact / Rollback) as overrides of the
  canonical long-form layout.
- Renamed the canonical section to "Canonical Structure (Long-Form:
  Minor / Weekly)" so the boundary is visible.
- Added Hotfix entry to Release Size Heuristics.
- Added a Hotfix subsection to Quick Checklist so the verification
  gates differ from long-form (no metric line / no Contributors / Owner
  resolved via gh).
2026-05-13 02:57:06 +08:00
YuTengjing 181b7eb117 🐛 fix: remove signin captcha flow (#14573) 2026-05-13 02:57:06 +08:00
YuTengjing 2bdd901ce2 🐛 fix: add temporary email auth error locale (#14564) 2026-05-13 02:57:06 +08:00
Rdmclin2 e4b5e52aff 🐛 fix: add bot callback service (#14570)
fix: add bot callback service
2026-05-13 02:57:06 +08:00
LiJian 1a6e07b5ef 🐛 fix: sanitize sensitive comments and examples from production JS bundle (#14557)
* 🐛 fix: sanitize sensitive comments and examples from production JS bundle

- Replace app.example.com with RFC 2606 example.com in agent-browser skill content
- Replace password-stdin examples with interactive auth prompts
- Remove hardcoded password-like strings from code examples
- Reword flagged code comments in page-agent system role

Addresses TAC Security CASA Tier 2 DAST Info findings:
Information Disclosure - Suspicious Comments (CWE-615)

The flagged strings appeared in SPA production bundles:
- /_spa/assets/chat-*.js
- /_spa/assets/index-*.js

* 🐛 fix: revert --interactive to --password-stdin in auth vault examples

The --interactive flag does not exist in agent-browser CLI (only --password
and --password-stdin are supported). Using --interactive would cause auth
save to fail and block login workflows.

Reverted both auth vault examples to use echo | --password-stdin pattern,
which pipes the password via stdin — the recommended secure approach.
2026-05-13 02:57:06 +08:00
Arvin Xu a7cc553212 💄 style(task): activity card stop run + register /tasks in SPA proxy (#14559)
*  feat(task): add stop run action to activity card menu

Surface the existing cancelTopic flow in the task detail activity card so
users can interrupt a running topic without opening the chat drawer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task): confirm before stopping a running topic

Wrap the new Stop run action in a confirmModal so an accidental click can't
silently abort an in-flight run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(spa): register /tasks and /task in SPA proxy matcher

Without these matcher entries, the Next.js middleware never rewrote /tasks
and /task/:taskId to the SPA catch-all, so the activity feed entries 404'd
in production builds even though the routes were wired in the SPA router.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:06 +08:00
YuTengjing c208723904 💄 style: update auth captcha retry copy (#14561) 2026-05-13 02:57:06 +08:00
Rdmclin2 760a342557 🐛 fix: multiple account link (#14562)
* feat: avoid rebind link same account

* chore: update i18n locales

* feat: avoid discord account misslink

* feat: support slack account mis match

* fix: avoid claim conflict
2026-05-13 02:57:06 +08:00
Arvin Xu ce08b9b116 feat(agent-runtime): persist agent operations to agent_operations table (#14736)
*  feat(agent-runtime): persist agent operations to `agent_operations` table

Wire start-time INSERT and terminal UPDATE into the agent runtime so
operation history outlives the 2-hour Redis TTL. Adds
`AgentOperationModel` with `recordStart` / `recordCompletion` /
`findById` (scoped by userId so a leaked operationId can't flip another
user's row) and threads both calls through `CompletionLifecycle`, which
now owns both ends of the persistence lifecycle. Also plumbs
`parentOperationId` through `ExecAgentParams` → `OperationCreationParams`
so sub-agent invocations carry their parent lineage. Per-step aggregate
updates are intentionally out of scope.

Refs LOBE-8848

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-runtime): update CompletionLifecycle test constructor to 2 args

CompletionLifecycle now constructs MessageModel internally from
(db, userId), so the test builder passing a third messageModel arg
tripped tsgo --noEmit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:53:35 +08:00
Rdmclin2 efa57ad4ab feat: support slack mpim and fix discord dm problem (#14733)
* feat: support mpim

* chore: add errorMsg

* fix: discord commands thinking error

* fix: discord typing error

* feat: add oauth process for discord
2026-05-13 00:55:25 +07:00
Arvin Xu 844f885b60 🐛 fix(hetero-agent): wire AskUserBridge response events to renderer (#14732)
Close the wire-protocol gap that left CC's AskUserQuestion form stuck on
"pending" after the bridge gave up. AskUserBridge now emits an
agent_intervention_response event on every terminal path (timeout,
user resolve, cancel, cancelAll), and heterogeneousAgentExecutor handles
it by stamping pluginIntervention.status = 'rejected' for timeout /
session_ended (user-driven paths are filtered out — already optimistic).

Layered defenses so a late Submit no longer throws "Operation not found":
- cleanupCompletedOperations: find→filter so every messageOperationMap
  entry pointing to the cleaned op is removed (assistant + tool message
  pairs previously stranded one entry as a dangling reference).
- internal_getConversationContext: log + fall back to global state when
  the op has been GC'd, instead of throwing.
- submitHeteroIntervention: detect a stale opId before passing it into
  the optimistic chain.

Scoped as a short-term backstop until LOBE-8746 retires the AskUser MCP
bridge entirely.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:46:32 +08:00
Arvin Xu ccddbaa25d ♻️ refactor(builtin-tool): move sub-agent dispatch from lobe-gtd to lobe-agent (#14715)
* ♻️ refactor(builtin-tool): move sub-agent dispatch from lobe-gtd to lobe-agent

Move the `execTask` / `execTasks` capability out of `packages/builtin-tool-gtd/`
and into `packages/builtin-tool-lobe-agent/`, renaming the public APIs to
`callSubAgent` / `callSubAgents`. The "subtask" naming inside GTD overlapped
with the new lobe-task tool's task model and conflated planning with
sub-agent dispatch.

- API names: `execTask` → `callSubAgent`, `execTasks` → `callSubAgents`
- TS types: `ExecTaskParams` → `CallSubAgentParams`, etc.; introduce
  `SubAgentTask` to replace `ExecTaskItem`
- Client UI (Inspector / Render / Streaming) ported under
  `packages/builtin-tool-lobe-agent/src/client/`
- Central registries (`packages/builtin-tools/src/{inspectors,renders,streamings}.ts`)
  updated to register lobe-agent
- GTD `meta.description` and system role no longer mention async tasks;
  they point to lobe-agent for sub-agent dispatch
- `isSubTask` filtering in `agentConfigResolver` now excludes `lobe-agent`
  (new owner of sub-agent dispatch) instead of `lobe-gtd`
- i18n: new `builtins.lobe-agent.apiName.callSubAgent*` and
  `workflow.toolDisplayName.callSubAgent*` keys in default/zh-CN/en-US

Kept the executor's emitted `state.type` values (`execTask` / `execTasks` /
`execClientTask` / `execClientTasks`) unchanged so the agent-runtime
instruction layer (`exec_task` / `exec_tasks` / `exec_client_task*`) and all
downstream tests / heterogeneous executors (`builtin-tool-agent-management`,
server `agentManagement` runtime) continue to work without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(chat): rename isSubTask flag to isSubAgent

After moving sub-agent dispatch from lobe-gtd to lobe-agent, the flag name
no longer matches what it controls. Rename `isSubTask` → `isSubAgent` across
the chat / agent runtime layer and update related comments and test labels.

- `agentConfigResolver` context field + filter helper
- `streamingExecutor.internal_createAgentState` + `executeClientAgent`
  signatures and call sites
- `createAgentExecutors` (exec_task / exec_client_task handlers) and
  `GroupOrchestrationExecutors` (batch_exec_async_tasks)
- `chatService.createAssistantMessageStream` `resolvedAgentConfig` docs
- Test descriptions and assertions in `agentConfigResolver.test.ts` and
  `streamingExecutor.test.ts`

No behavior change — the flag's filter target (`lobe-agent` identifier) is
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(agent-runtime): rename exec_task wire identifiers to exec_sub_agent

Bring the agent-runtime "wire" naming in line with the lobe-agent
callSubAgent / callSubAgents API rename. Three layers are renamed in lockstep
to keep the bridge between tool executors and the runtime consistent:

1. Tool-emitted state.type discriminators
   - 'execTask' → 'execSubAgent'
   - 'execTasks' → 'execSubAgents'
   - 'execClientTask' → 'execClientSubAgent'
   - 'execClientTasks' → 'execClientSubAgents'

2. AgentInstruction.type and matching TS interfaces
   - 'exec_task' / 'exec_tasks' / 'exec_client_task' / 'exec_client_tasks'
     → 'exec_sub_agent' / 'exec_sub_agents' / 'exec_client_sub_agent' /
       'exec_client_sub_agents'
   - AgentInstructionExecTask → AgentInstructionExecSubAgent (and the three
     siblings)
   - ExecTaskItem → SubAgentTask

3. AgentRuntimeContext.phase + matching payload types
   - 'task_result' → 'sub_agent_result'
   - 'tasks_batch_result' → 'sub_agents_batch_result'
   - TaskResultPayload → SubAgentResultPayload
   - TasksBatchResultPayload → SubAgentsBatchResultPayload

Also renames the operation-type discriminator 'execClientTask' /
'execClientTasks' to 'execClientSubAgent' / 'execClientSubAgents' and updates
its locale string in default / zh-CN / en-US.

Tests / fixtures / mocks updated in lockstep:
- packages/agent-runtime/src/agents/{GeneralChatAgent.ts,__tests__/...}
- packages/builtin-tool-{lobe-agent,agent-management}/src/...
- src/server/services/toolExecution/serverRuntimes/agentManagement.ts
- packages/agent-mock/src/cases/builtins/todo-write-stress.ts (helper renamed
  to callSubAgent)
- src/store/chat/agents/createAgentExecutors.ts + exec-task / exec-tasks tests
  + fixtures/mockInstructions.ts (createExecSubAgent[s]Instruction)
- src/store/chat/slices/aiChat/actions/streamingExecutor.ts (phase check)
- packages/conversation-flow/src/__tests__/fixtures/**/*.json (8 fixtures
  retargeted from lobe-gtd/execTask[s] to lobe-agent/callSubAgent[s] with the
  new state.type wire values)

No behavior change — the agent runtime, executors and tests all go through
the same code paths; only the strings on the wire change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(builtin-tool): absorb GTD tool (plan + todo) into lobe-agent

Delete `packages/builtin-tool-gtd/` and fold its full surface — plan, todo,
ExecutionRuntime, all client UI (Inspector / Render / Streaming /
Intervention / SortableTodoList) and the system role — into
`packages/builtin-tool-lobe-agent/`. Single `lobe-agent` identifier now
owns: plan + todo management, sub-agent dispatch, and visual media analysis.

Also restructures the lobe-agent package so the executor lives under
`./client/` alongside the UI it ships with, and drops the dedicated
`./executor` export — consumers go through `./client` for everything
client-side.

Package-level changes:
- DELETE `packages/builtin-tool-gtd/` entirely.
- `packages/builtin-tool-lobe-agent/`
  - Move `src/executor/` → `src/client/executor/`. Drop `./executor` from
    `package.json` exports; expose `lobeAgentExecutor` via `./client` only.
  - Rename `GTDExecutionRuntime` → `PlanExecutionRuntime` and place under
    `src/client/executor/PlanRuntime/`. Re-export from package root so the
    server runtime can consume it without pulling in client UI deps.
  - Extend `LobeAgentExecutor` with `createPlan` / `updatePlan` /
    `createTodos` / `updateTodos` / `clearTodos`, all delegated to the
    shared runtime.
  - Add Plan + Todo API entries to the manifest (with their original
    descriptions, humanIntervention, renderDisplayControl).
  - Move all GTD client UI verbatim:
    `Inspector/{ClearTodos,CreatePlan,CreateTodos,UpdatePlan,UpdateTodos}`,
    `Render/{CreatePlan,TodoList}`, `Streaming/CreatePlan`,
    `Intervention/{AddTodo,ClearTodos,CreatePlan}`,
    `components/SortableTodoList`. Register them in
    `LobeAgentInspectors / Renders / Streamings`, add new
    `LobeAgentInterventions`.
  - Merge GTD system role into lobe-agent's (`<plan_and_todos>` plus the
    existing `<sub_agents>` and `<run_in_client>` sections).
  - `package.json`: pick up `@lobechat/prompts` dep and `@lobehub/editor` +
    `antd` + `lucide-react` peer-deps inherited from GTD.

Central registries (`packages/builtin-tools/src/*`) and consumers:
- Remove every `GTDManifest / Inspectors / Renders / Streamings /
  Interventions` import + registration; existing `LobeAgent*` registrations
  now cover them.
- Replace `[GTDManifest.identifier]: GTDInterventions` with
  `[LobeAgentManifest.identifier]: LobeAgentInterventions`.
- Drop `@lobechat/builtin-tool-gtd` workspace dep from
  `packages/builtin-tools/package.json`, `packages/builtin-agents/package.json`
  and root `package.json`.
- Remove `gtdExecutor` from `src/store/tool/slices/builtin/executors/index.ts`;
  switch `lobeAgentExecutor` import to `/client`.
- Replace `serverRuntimes/gtd.ts` with a service factory
  `serverRuntimes/lobeAgentPlan.ts` (`createServerPlanRuntimeService`).
  `serverRuntimes/lobeAgent.ts` instantiates `PlanExecutionRuntime` with
  that service so the registry exposes one runtime per `lobe-agent`
  identifier covering both visual analysis and plan/todo.
- `services/chat/mecha/contextEngineering.ts`: gate plan/todo injection on
  `LobeAgentIdentifier` instead of `GTDIdentifier`.
- `agentConfigResolver.test.ts`: switch fixture plugin IDs to
  `LobeAgentIdentifier`.
- `packages/const/src/recommendedSkill.ts`: drop the standalone `lobe-gtd`
  recommendation — `lobe-agent` already covers it via `defaultToolIds`.

i18n migration (default + zh-CN + en-US; other locales regenerate on
`pnpm i18n`):
- `builtins.lobe-gtd.*` → `builtins.lobe-agent.*` in `plugin.ts/json`.
- `lobe-gtd.*` (tool namespace) → `lobe-agent.*` in `tool.ts/json`.
- Remove `tools.builtins.lobe-gtd.{description,readme,title}` from
  `setting.ts/json` (lobe-agent has its own meta now).
- Update all client component `t(...)` keys to the new namespace.

Mocks / fixtures / tests:
- `packages/agent-mock/src/cases/builtins/todo-write-stress.ts`: all
  `identifier: 'lobe-gtd'` → `'lobe-agent'`; helper comments updated.
- `packages/types/src/stepContext.ts`: comment refers to
  `builtin-tool-lobe-agent` (the only consumer of `StepContextTodoItem`).
- `packages/model-runtime/src/core/streams/google/google-ai.test.ts`:
  function-call names from `lobe-gtd____createPlan` etc. → `lobe-agent____*`.
- `src/store/chat/slices/message/selectors/dbMessage.test.ts`: same.
- `src/features/DevPanel/RenderGallery/fixtures/lobe-gtd.ts` deleted; its
  plan/todo fixtures are folded into `fixtures/lobe-agent.ts` alongside the
  existing `callSubAgent[s]` ones.
- Replace `console.log` → `console.info` in moved client components to
  satisfy lobe-agent's stricter ESLint rules (GTD package allowed
  `console.log`; lobe-agent inherits the repo-wide `no-console` rule).

No behavior change for end users: `lobe-agent` now owns all the APIs,
identifiers, and UI that previously lived in `lobe-gtd`, but as a single
consolidated package under a single tool identifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(context-engine): drop residual GTD naming, rename to PlanInjector / TodoInjector

Follow-up to 9ca5c9d (which absorbed the GTD tool package into lobe-agent).
That commit moved the package surface but left the GTD vocabulary embedded
in context-engine providers, types, metadata fields, XML tags, and a pile
of comments. This change finishes the sweep so the only remaining GTD
references are user-facing docs and the legitimate Productivity & GTD Coach
methodology suggestion.

context-engine
- `GTDPlanInjector` → `PlanInjector`; types `GTDPlan`/`GTDPlanInjectorConfig`
  → `Plan`/`PlanInjectorConfig`; metadata `gtdPlanId`/`gtdPlanInjected` →
  `planId`/`planInjected`; XML tag `<gtd_plan>` → `<plan>`; debug channel
  `provider:GTDPlanInjector` → `provider:PlanInjector`.
- `GTDTodoInjector` → `TodoInjector`; types `GTDTodoItem`/`GTDTodoList`/
  `GTDTodoStatus`/`GTDTodoInjectorConfig` → `TodoItem`/`TodoList`/
  `TodoStatus`/`TodoInjectorConfig`; metadata `gtdTodo*` → `todo*`;
  XML tag `<gtd_todos>` → `<todos>`, wrapper `gtd_todo_context` →
  `todo_context`; debug channel renamed similarly.
- `MessagesEngineParams.gtd?: GTDConfig` → `planTodo?: PlanTodoConfig`;
  internal vars `isGTDPlanEnabled`/`isGTDTodoEnabled` →
  `isPlanEnabled`/`isTodoEnabled`. Re-exports updated in `providers/index.ts`
  and `engine/messages/{index,types}.ts`.

prompts
- `packages/prompts/src/prompts/gtd/` → `planTodo/` (only export was
  `formatTodoStateSummary`, which kept its name). Updated `prompts/index.ts`
  re-export.

src/services
- `contextEngineering.ts`: `GTDConfig` import → `PlanTodoConfig`;
  `isGTDEnabled`/`gtdConfig` → `isPlanTodoEnabled`/`planTodoConfig`; payload
  field `gtd` → `planTodo`; log message wording.

Tests
- `dbMessage.test.ts`: helper `createGTDToolMessage` →
  `createLobeAgentToolMessage`; `gtdMessage` → `lobeAgentMessage`; all `it`
  descriptions reworded to "lobe-agent" instead of "GTD".
- `agentConfigResolver.test.ts`: test descriptions reworded.

Comments / docs (no behavior change)
- agent-runtime (`instruction.ts`, `runtime.ts`, `generalAgent.ts`,
  `messageSelectors.ts`), `types/{stepContext,tool/builtin}.ts`,
  `builtin-agents/group-supervisor`, `builtin-tool-claude-code/types.ts`,
  `builtin-tool-lobe-agent/Render/TodoList`, `createAgentExecutors.ts:1426`,
  `AssistantGroup/{constants,Fallback.test}`, `agent-mock/todo-write-stress`,
  `.agents/skills/builtin-tool/references/architecture.md`.

Intentionally left alone
- `docs/usage/agent/gtd.{mdx,zh-CN.mdx}` and other docs — user-facing
  product brand "GTD Tools".
- `src/locales/default/suggestQuestions.ts` "Productivity & GTD Coach" —
  references the methodology, not the tool.
- `ToolSystemRoleProvider.test.ts` `'gtd-tool'` fixture — generic test
  identifier, unrelated.
- Translated locale files still carrying `lobe-gtd.*` keys — regenerated by
  `pnpm i18n` from the updated default namespace.

Verified: `bun run type-check` passes; touched test files
(dbMessage, agentConfigResolver) and full context-engine + prompts test
suites pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(builtin-tool-lobe-agent): reset TodoList auto-save status to idle

`performSave` (the debounced auto-save path) was leaving `saveStatus` stuck
on 'saved' forever — `saveNow` had the 1.5s setTimeout-to-idle but the
auto-save twin didn't, so the inline indicator never eased back to idle
after a settle. Add the same idle-reset to performSave so both paths
behave the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:13:04 +08:00
Arvin Xu 4ffce4fbbf 💄 style: use @lobehub/ui built-in HtmlPreview instead of custom component (#14703)
* 💄 style(home,i18n): use 已阅 for brief confirm/confirmDone in zh-CN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): use 确认完成 for brief.action.confirmDone in zh-CN

confirmDone signals the terminal transition (task marked complete),
not just dismissing the brief, so 已阅 loses the semantic distinction
from `confirm`. Use 确认完成 to match the EN intent ("Confirm complete").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: use @lobehub/ui built-in HtmlPreview instead of custom component

- Upgrade @lobehub/ui from ^5.10.1 to ^5.10.4
- Replace custom HtmlPreviewAction with lobe-ui's enableHtmlPreview
- Wire lobe-ui's onExpand callback to existing HtmlPreviewDrawer
- Remove HtmlPreviewAction.tsx (no longer needed)
- Keep HtmlPreviewDrawer for the expanded full-screen view

* 🐛 fix(task): sync useMarkdown destructuring with assistant MessageContent

* 🐛 fix(task): correct mangled search.X JSX expressions in MessageContent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(review): move revert icon to right edge of file row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:08:18 +08:00
LobeHub Bot 9da8ed0a6c 🌐 chore: translate non-English comments to English in src (#14654)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 00:54:54 +08:00
Arvin Xu e8ab37e5d4 🐛 fix(home): blank user bubble when sending the placeholder hint (#14678)
When the home input was empty and the user clicked send, `useSend`
correctly fell back to the daily-brief hint for `message`, but it also
forwarded `mainInputEditor.getJSONState()` as `editorData`. An empty
editor still returns a non-null JSON state (e.g. `{ type: 'doc' }`),
which makes `UserMessageContent.hasEditorData` truthy — so the renderer
took the RichTextMessage branch and drew nothing, while the agent
happily processed the hint text behind a blank user bubble.

Skip `editorData` when the hint is being used so the renderer falls
back to the markdown `content`. Adds a regression test.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:51:41 +08:00
Arvin Xu 9dff0acd36 feat(database): add agent_operations table (#14416)
 feat(database): add agent_operations table

Adds an `agent_operations` table to persist agent runtime operations
beyond the 2-hour Redis TTL. Each row captures one agent operation
(operationId) with denormalized cost/token aggregates, lifecycle
timestamps, runtime config snapshot, and a `trace_s3_key` pointer to
the full ExecutionSnapshot in S3.

- `user_id` is intentionally not a FK so operation history survives
  user deletion (auditable historical data).
- `agent_id` / `topic_id` / `thread_id` / `task_id` / `chat_group_id`
  use ON DELETE SET NULL to preserve operations when their parent
  entity is removed.
- `parent_operation_id` self-references for sub-agent (callAgent) ops.
- `human_interventions` and `human_waiting_time_ms` are nullable since
  most operations have no human interaction at all.
- Indexes optimize per-user listing and per-status / per-entity lookups;
  `metadata` has a GIN index for jsonb filters.
2026-05-13 00:51:03 +08:00
Innei 84c89f9c03 🐛 fix(conversation): prevent synthetic scroll from shrinking spacer (#14584)
🐛 fix: prevent synthetic scroll from shrinking spacer
2026-05-13 00:18:10 +08:00
Arvin Xu a5ea379079 ♻️ refactor(agent-runtime): extract CompletionLifecycle, HumanInterventionHandler, stepPresentation (#14441)
* ♻️ refactor(agent-runtime): extract CompletionLifecycle

Pull terminal-state handling out of AgentRuntimeService into a dedicated
class:

- buildLifecycleEvent (was buildCompletionLifecycleEvent)
- emitSignalEvents (was emitCompletionSignalEvents)
- dispatchHooks (was dispatchCompletionHooks)
- extractErrorMessage

These four methods formed one cohesive vertical: build the lifecycle
event payload, emit completion AgentSignal source events, dispatch
onComplete/onError hooks, and write error back onto the assistant
message row. extractErrorMessage was a private helper used by all three
plus by the trace-snapshot finalize call site, so it becomes a public
method on the class.

Call sites in executeStep / executeSync change from
`this.{emit|dispatch|extract...}` to `this.completionLifecycle.{...}`.

Tests: extractErrorMessage.test.ts → CompletionLifecycle.test.ts,
instantiating CompletionLifecycle directly instead of going through
AgentRuntimeService — drops a pile of unrelated mocks.

AgentRuntimeService.ts: 2084 → 1918 (-166).

All 81 agentRuntime tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(agent-runtime): extract HumanInterventionHandler

Pull the 165-line `handleHumanIntervention` method out of
AgentRuntimeService into its own class, splitting the three branches
(approve / rejectAndContinue / rejectAndHalt) into private methods so
each fits in one screen. Routing in `process()` now reads top-to-bottom:
detect approval, then rejection, then unsupported humanInput.

The handler depends only on `serverDB` (for the messagePlugins lookup)
and `messageModel` (for tool/plugin updates) — much narrower than
AgentRuntimeService's full surface, so the extracted unit is easier to
unit-test in isolation.

Drop the unused `runtime: AgentRuntime` parameter from the public API:
the original method threaded it through but never called it.

Tests: handleHumanIntervention.test.ts → HumanInterventionHandler.test.ts
— same 17 cases, but instantiate the handler directly instead of
constructing a full AgentRuntimeService with 11 module mocks. Tighter
arrange step, same coverage.

AgentRuntimeService.ts: 1918 → 1742 (-176).

All 81 agentRuntime tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(agent-runtime): extract step presentation builder

Pull the ~150-line `phase`-branching block out of executeStep into a
pure `buildStepPresentation` function. The block did three things in
sequence: derive content/reasoning/toolsCalling/toolsResult from the
runtime step result, build a one-line stepSummary for logging, and
assemble the StepPresentationData DTO consumed by afterStep hooks /
snapshot recorder / callbacks.

The function takes only the stepResult and an executionTimeMs; no
service state needed. Comes with a `formatTokenCount` helper for the
log line (12345 → 12.3k, 2_500_000 → 2.5m).

executeStep keeps the log call inline (one line, references presentation
fields directly) and reads `content` / `toolsCalling` off presentation
for downstream tracking + truncation logic.

13 new unit tests: phase=tool_result (json + string + isSuccess paths),
phase=tools_batch_result, done event, llm_result with content/reasoning/
tools, empty fallback, cumulative usage zero-fallback, stepUsage
forwarding, and formatTokenCount edges.

AgentRuntimeService.ts: 1742 → 1601 (-141).

All 94 agentRuntime tests pass (was 81, +13 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:12:15 +08:00
Arvin Xu b9fb68464d 🐛 fix(task-card): localize task card date independent of dayjs global locale (#14730)
* 🐛 fix(task-card): localize date format independent of dayjs global locale

Task card was rendering "5月 12" under English UI because t('time.formatThisYear')
returned the English "MMM D" format, but dayjs's global locale was still zh-cn,
making MMM resolve to the Chinese short month name. Thread the i18n language
into formatTaskItemDate so the date is rendered with the same locale as the
format string, decoupling it from dayjs's global state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task-card): import missing GenericItemType + type Run now onClick

Pre-existing CI regression from #14727 surfacing on every PR: the Run now
context menu satisfies-clause references GenericItemType without importing
it, and the onClick lacks a MenuInfo annotation, so tsgo widens the divider
literal's `type` to `string` and rejects the whole context menu array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:31:51 +08:00
Arvin Xu ca873e3c34 🐛 fix(web-crawler): cap response body size to prevent serverless OOM (#14660)
* 🐛 fix(web-crawler): cap response body size to prevent serverless OOM

Production saw repeated SIGABRT crashes on `/trpc/tools/search.webSearch`
where Node aborted with V8 "allocation failed" — the naive crawler buffered
entire response bodies into heap before the 1 MB downstream truncation could
apply, so a single large page (or a batch of three under default
concurrency=3) could push rss past the lambda memory ceiling.

- ssrfSafeFetch: add opt-in `maxContentLength` that streams the response
  body via `for await` and stops at the cap (soft truncation — still a
  successful response). Breaking the iterator destroys the underlying
  stream and releases the connection. Default behaviour (full
  `arrayBuffer()` read) unchanged when the option is absent.
- naive crawler: pass `maxContentLength: MAX_HTML_SIZE` so any body beyond
  1 MB is dropped at the network layer instead of being materialised in heap.
- htmlToMarkdown: explicitly call `window.happyDOM.close()` in a finally
  block so the parsed DOM tree is released as soon as parsing finishes,
  rather than waiting for the function scope to drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(ssrf-safe-fetch): add OOM regression tests for response body cap

Verify that the maxContentLength cap actually prevents the production SIGABRT
scenario, not just produces a truncated body.

- Source-pull bound: a body source with 200 MB available, capped at 1 MB,
  must not be drained beyond ~1 MB. Asserts on bytes pulled from the
  generator, which is the property that prevents OOM.
- Concurrency bound: matches production CRAWL_CONCURRENCY=3 — three
  concurrent oversized fetches should pull at most ~3 MB total, not 300 MB.
- Heap-delta bound (gated on --expose-gc): under real GC pressure,
  fetching a 50 MB body with a 1 MB cap should grow heapUsed by < 10 MB.
  Run with `NODE_OPTIONS=--expose-gc bunx vitest run` to exercise; skipped
  by default so CI doesn't false-fail on GC timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:21:08 +08:00
Innei ddc67bc3db 🐛 fix(desktop): focus onboarding auth success state (#14694) 2026-05-12 22:57:34 +08:00
Arvin Xu dfb5e0176e feat(markdown): user_feedback card + task card polish + Run now context menu (#14727)
*  feat(markdown): render <user_feedback> task prompt blocks as a card

`buildTaskRunPrompt` wraps the user's pre-run comments in a
`<user_feedback>` block alongside `<task>`. The Task plugin captured
`<task>` into a card, but `<user_feedback>` had no plugin and leaked
into the chat as raw XML. Because CommonMark only treats tag names
matching `[a-zA-Z][a-zA-Z0-9-]*` as html, the underscore in
`user_feedback` puts the opening/closing tags inside a `paragraph` as
plain text — so the new remark plugin walks paragraph children rather
than html nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task-card): drop standalone status row + Agent/Parent/Topics, inline semantic status badge

The status/Priority row, Agent, Parent and Topics fields aren't useful
when the task card is rendered inside the topic chat drawer (the drawer
already exposes that context). Move the task status to a compact badge
beside the identifier and reuse `taskDetail.status.*` for the label so
"scheduled" reads as "Scheduled" / "已排期".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): compact one-line header + left-border quote-style card

Slims the card down to a single 12px header line ("User feedback · N
comments") with a small 12px icon, and wraps the whole block in a
subtle fill + 2px left-border accent so it reads as a quoted aside and
visually separates from the task card that follows in the same user
message body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): drop fill + radius, render as plain left-rail blockquote

The filled card competed visually with the unstyled task block that
sits beside it in the same message body. Reducing to a 2px left-rail
quote without background or border-radius lets both blocks read as
parts of the same user message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): collapsible card with task-style head + bottom divider

Default-collapsed `<details>` whose summary mirrors the task title row
(32px icon + bold label + small count badge), with a bottom split-line
that doubles as a divider between the user feedback head and the task
card that follows in the same message body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): strip default markdown details card chrome

@lobehub/ui Markdown applies bg + padding (0.75em 1em) + box-shadow +
border-radius to every nested <details>, which made the user_feedback
head read as a wide standalone card sitting awkwardly on top of the
inline task title. Override the chrome (with !important — the lib
selector wins on specificity otherwise) so the head sits flat in the
message body, with only the bottom split line separating it from the
task that follows. The lib's right-side disclosure chevron is kept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(user-feedback): match task card's 12px symmetric divider spacing

Add a 12px margin-bottom so the gap below the user_feedback bottom rule
mirrors the 12px above it, matching the symmetric 12px the task card
already uses around its own internal divider. Without this, the
user_feedback rule sat flush against the T-31 row while the next rule
below T-31 had a 12px gap on both sides — visually uneven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task-card): drop status badge from task title row

The task drawer header and the schedule strip on the task detail page
already convey status; surfacing it again on the task card inside the
chat body just added noise. Drop the badge along with the now-unused
KNOWN_STATUSES / isKnownStatus / TaskStatusIcon / useTranslation
plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(tasks): add "Run now" item to task card context menu

Available only for backlog and completed tasks; mirrors the inbox-agent
fallback used by the detail-page Run Now action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(topic-list): preserve `#` icon placeholder for heterogeneous agents

Returning null for the icon slot collapsed the row layout, so titles on
heterogeneous-agent topics (Claude Code, Codex, …) no longer aligned
with sibling rows. Render the same HashIcon with visibility:hidden so
the box is preserved without showing the glyph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:39:11 +08:00
brone1323 a109d22c8d 🌐 i18n: add missing task-schedule and review strings to 16 locales (#14728)
🌐 i18n: add missing translations for task-schedule and review keys across 16 locales

Adds 14 missing i18n keys to all non-zh-CN locales (ar, bg-BG, de-DE,
es-ES, fa-IR, fr-FR, it-IT, ja-JP, ko-KR, nl-NL, pl-PL, pt-BR, ru-RU,
tr-TR, vi-VN, zh-TW):

chat.json (11 keys):
- taskSchedule.summary.everyNHoursHalfPast
- taskSchedule.summary.hourlyHalfPast
- taskSchedule.timezoneSearchEmpty
- taskSchedule.timezoneSearchPlaceholder
- workingPanel.review.revert (and 7 sub-keys)

plugin.json (1 key):
- builtins.lobe-task.apiName.setTaskSchedule

setting.json (2 keys):
- serviceModel.modelAssignments.title
- serviceModel.optionalFeatures.title

These were added in recent commits but the automated i18n sync had not
yet propagated them to non-Chinese locales.
2026-05-12 22:13:31 +08:00
Innei b8587cef73 💄 style: polish desktop header icons, sidebar density, and task menus (#14724)
* 💄 style: shrink desktop header icons and tighten sidebar/home density

Switches all desktop header action icons from DESKTOP_HEADER_ICON_SIZE to
DESKTOP_HEADER_ICON_SMALL_SIZE, and tightens vertical gaps in the home
sidebar, recents list, and nav header layout for a denser, calmer look.

* ♻️ refactor(agent-tasks): migrate task menus and scheduler select to @lobehub/ui base-ui

- TaskPriorityTag / TaskStatusTag: replace antd Dropdown with base-ui
  DropdownMenu and adopt the ContextMenuItem / MenuInfo typings.
- useTaskItemContextMenu: drop the DOM data-attribute submenu marker in
  favour of an internal activeSubmenuRef tracked via onOpenChange.
- TaskScheduleConfig / SchedulerForm: swap @lobehub/ui Select for the
  base-ui Select and replace the custom SearchBar dropdownRender with
  antd Select showSearch for timezone filtering.

* ♻️ refactor(review): migrate review dropdowns to @lobehub/ui base-ui DropdownMenu

Swap the antd Dropdown trios (mode picker, base-ref picker, more menu) in
the agent working-sidebar Review pane for the base-ui driven DropdownMenu,
matching the recent task menus / scheduler migration. Also tighten the
sidebar header paddingInline from 16 to 4 to align with the surrounding
density polish.

* 🐛 fix(tasks): replace unsupported onOpenChange with onTitleMouseEnter in context menu
2026-05-12 21:42:28 +08:00
René Wang ba750161ca fix: Docs image (#14726)
fix: image
2026-05-12 20:19:55 +08:00
René Wang 60c55b731c 📝 docs: add May 11 weekly changelog (#14651) 2026-05-12 20:06:45 +08:00
Arvin Xu 09230e7af5 🐛 fix(desktop): detect Windows npm .cmd shims for CLI agents (claude/codex/…) (#14720) 2026-05-12 17:46:48 +08:00
LobeHub Bot fac91067ce 🌐 chore: translate non-English comments to English in cli-migrate (#14708)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:36:22 +08:00
Arvin Xu 0b5c1fb53f ⬆️ chore: bump @lobehub/ui to 5.10.5 2026-05-12 17:17:02 +08:00
Arvin Xu 5d21b9e149 💄 style(review-panel): hover revert button to discard per-file working-tree changes (#14716)
 feat(review-panel): hover revert button to discard per-file working-tree changes

Add a hover-revealed Undo icon to each file row in the Review panel's
unstaged view. Clicking opens a Popconfirm; confirming runs a new
`git.revertGitFile` IPC that restores the file from HEAD (or unstages +
deletes when the path doesn't exist at HEAD, covering staged-add and
untracked entries).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:03:31 +08:00
Innei 9e0e76fda2 feat(documents): add optimistic create/delete and inline rename for document tree (#14714)
- Insert pending rows immediately on create folder/document, with
  optimistic SWR mutation that rolls back on server error
- Auto-focus rename input on newly created items via onPendingInserted
  callback
- Defer rename commits for pending rows until the server create resolves,
  then rename against the real row id
- Optimistic recursive delete closes the confirm modal instantly, removes
  target + descendants from the tree, and rolls back on failure
- Fix folder path canonicalization in ExplorerTree rename lookup
  (toCanonicalTreePath ensures trailing slash for folders)
- Export getItemPathFromEventPath for composed-path–based item resolution
- Add unit tests for toCanonicalTreePath and ExplorerTree event helpers
2026-05-12 16:40:17 +08:00
Arvin Xu 66b9c67494 fix: update Task page placeholder copy (#14704)
* fix: update Task page placeholder copy

* fix: update Task page placeholder copy (en-US)
2026-05-12 16:25:23 +08:00
Innei 2d4822ad7b 💄 style: standardize header action icon sizes (#14717)
💄 style: standardize header action icons to DESKTOP_HEADER_ICON_SMALL_SIZE

Unify icon sizing across sidebar and header action buttons by replacing
hardcoded sizes and DESKTOP_HEADER_ICON_SIZE with
DESKTOP_HEADER_ICON_SMALL_SIZE for consistent visual density.

Affected components:
- SideBarHeaderLayout back button
- ToggleLeftPanelButton default size
- BackButton default size
- Agent sidebar header chevron
- InboxButton notification icon
2026-05-12 15:48:56 +08:00
Innei a50b230fae feat(devtools): add dev-only feature flag override panel (#14565)
Add a client-side feature flag override panel that lives behind a
floating button in dev builds. Overrides are persisted to localStorage
and merged into useServerConfigStore.featureFlags so existing flag
consumers see the toggled value without any callsite changes.

The panel is gated by NODE_ENV plus a localStorage opt-in
(LOBE_DEV_FEATURE_FLAG_PANEL_ENABLED = "1"); prod builds tree-shake
the entire feature.
2026-05-12 15:33:51 +08:00
Arvin Xu 5d6d01601d 🐛 fix(builtin-tool-task): expose lobe-task and add setTaskSchedule (#14713)
*  feat(builtin-tool-task): expose lobe-task to users and add schedule config

The task tool is now generally available — flip it from a scenario-only
internal tool to a user-toggleable recommended skill, and let the LLM
configure recurring execution (cron or heartbeat) via createTask / editTask.

- Drop `discoverable: false` + `hidden: true` from TaskManifest registration
- Add `lobe-task` to RECOMMENDED_SKILLS so it stays installed by default
- Remove the USER_HIDDEN_BUILTIN_TOOL_IDS allowlist (only contained lobe-task);
  update selectors and AgentTool to stop filtering it out
- Extend createTask / createTasks / editTask with `automationMode`,
  `schedulePattern`, `scheduleTimezone`, `heartbeatInterval`; editTask also
  accepts `maxExecutions`
- Route schedule columns through taskService.update and maxExecutions through
  taskService.updateConfig (server merges into tasks.config.schedule);
  refresh detail once at the end of editTask

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(builtin-tool-task): split schedule config into dedicated setTaskSchedule tool

editTask was the wrong place for schedule fields — schedule needs its own
verb so the LLM (and any future human-in-the-loop review) can audit cron /
heartbeat changes separately from generic field edits, and createTask should
stay a pure "make a task" verb without automation knobs.

- Drop automationMode / schedulePattern / scheduleTimezone / heartbeatInterval
  from createTask + createTasks, and drop them plus maxExecutions from editTask
- Add new `setTaskSchedule(identifier, automationMode?, schedulePattern?,
  scheduleTimezone?, heartbeatInterval?, maxExecutions?)` API with its own
  manifest entry, executor method, types, i18n key, and inspector
- Schedule columns still route through taskService.update; maxExecutions still
  routes through taskService.updateConfig (server merges into
  tasks.config.schedule) — same wiring, just moved into the dedicated tool
- Update systemRole to advertise setTaskSchedule + keep editTask description
  clean of schedule mentions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:25:53 +08:00
AmAzing- b49340742b feat: add service model assignments settings (#14712)
*  Add default agent model setting

* 💄 Refine service model assignments UI

* 💄 Clarify optional service model features
2026-05-12 14:59:09 +08:00
Innei b29816e927 🐛 fix(desktop): reset pendingLoginMethod on auth failure/cancel paths (#14695)
* 🐛 fix(desktop): focus onboarding auth success state

* 🐛 fix(desktop): reset pendingLoginMethod on auth failure/cancel paths

Clear pendingLoginMethod in authorizationFailed, authorizationProgress
cancelled, and remoteServerSyncError handlers to prevent users getting
stuck without a Get Started path when a re-auth attempt fails but a
prior authorization is still valid.

* Delete src/routes/(desktop)/desktop-onboarding/features/LoginStep.test.tsx

---------

Co-authored-by: Innei <inbox@innei.in>
2026-05-12 14:30:06 +08:00
Innei f03a1f0022 ♻️ refactor(spa): use __DEV__ define instead of process.env.NODE_ENV (#14696)
* ♻️ refactor(spa): use __DEV__ define instead of process.env.NODE_ENV

The Vite `__DEV__` define and its global type declaration are already
in place (plugins/vite/sharedRendererConfig.ts, src/types/global.d.ts).
Replace `process.env.NODE_ENV` checks across SPA-only files with the
`__DEV__` boolean so the bundler can statically eliminate dev-only
branches in production builds.

Server-side files (app/, server/, libs/next, libs/trpc, libs/better-auth,
envs, instrumentation) and modules that are also imported by Next.js
SSR pages (e.g. components/Loading/BrandTextLoading) are intentionally
left untouched to avoid runtime `__DEV__ is not defined` errors.

* fix(vitest): define __DEV__ and related constants for test environment

Vitest runs outside the Vite SPA build pipeline, so the __DEV__ define
injected by sharedRendererDefine was not available during tests. This
caused ReferenceError: __DEV__ is not defined in any test file that
transitively imports code using the __DEV__ constant.

Add a  block to vitest.config.mts that mirrors the SPA defines:
- __DEV__: true (test is not production)
- __CI__: mirrors process.env.CI
- __ELECTRON__/__MOBILE__: false (not testing platform-specific code)

* fix: replace missed isDevEnv reference with __DEV__ in AgentMockDevtools
2026-05-12 14:29:58 +08:00
Neko 29db177524 ♻️ refactor(agent-signal,prompts,database,builtin-tool-self-iteration): unified structure of service, unified tool, unified name and concepts (#14699) 2026-05-12 14:08:23 +08:00
Arvin Xu 5d8d2abe4c 🐛 fix(utils): cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit (#14711)
* 🐛 fix(utils): cap image binary at 3.75MB so base64 payload stays under Anthropic's 5MB limit

Anthropic enforces the 5MB image cap on the base64-encoded payload, not the
binary file. Base64 inflates by ~4/3, so a 4.7MB binary file becomes 6.27MB
once encoded and trips `messages.*.content.*.image.source.base64: image
exceeds 5 MB maximum`. The previous MAX_IMAGE_BYTES of 5MB matched against
file.size, letting these images through compression untouched.

Lower the threshold to floor(5MB * 3/4) ≈ 3.75MB in both the frontend
canvas compressor and the server-side Sharp fallback so the progressive
shrink loop keeps going until the base64 payload is safely under the cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(utils): tighten image binary cap to 3MB for extra base64 headroom

Drop MAX_IMAGE_BYTES from 3.75MB (exact 5MB-base64 boundary) to a flat 3MB
so the encoded payload lands around 4MB — clear of any per-provider rounding
or jitter at the 5MB hard limit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:04:12 +08:00
Arvin Xu 49c8d17e2c 🐛 fix(tasks): scheduler, hotkey, comment & TodoList polish (#14707)
* 🐛 fix(portal): allow TodoList to scroll when expanded content exceeds max-height

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(tasks): route 1–N hotkey to the open submenu instead of defaulting to status

The base-ui SubmenuTrigger doesn't propagate antd's `onTitleMouseEnter`, so
the hover ref in the right-click context menu never updated and every number
press fell back to the status submenu. The standalone Priority/Status tag
dropdowns also showed 1–N hints without binding any handler at all.

- Detect the currently open submenu via `data-popup-open` + a per-submenu
  `data-task-submenu` marker on the icon; numbers are ignored when no
  submenu is open.
- Install a keydown listener on TaskPriorityTag / TaskStatusTag while their
  dropdown is open so the hint numbers actually fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(scheduler): keep Continuous unchanged while editing Max runs

Clearing the Max runs input previously emitted maxExecutions=null, which the
form re-interpreted as Continuous and auto-checked the checkbox mid-edit
(disabling the input before the user could type the replacement number).

Track Continuous as its own state derived from the persisted prop. On clear
we hold the input empty locally without touching Continuous or emitting,
and unrelated emits fall back to the persisted value so they can't flip the
checkbox either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks): always show comment Send button and unify action labels

- Make the Send button visible by default in CommentInput / FeedbackInput
  (greyed out when empty) so the field reads as an input instead of vanishing
  affordance.
- Align topic action menu labels to Title Case (Stop Run / Open Run /
  Copy Topic ID / Copy Operation ID / Copy Link) to match the rest of the
  Action microcopy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  perf(scheduler): seed SchedulerForm from props once and own state locally

The previous prop→state useEffects re-synced every time the parent prop
updated, which during the async updateSchedule → refreshTaskDetail roundtrip
clobbered the user's in-flight edits with stale store values — felt awful
on rapid changes.

Drop the three sync useEffects and seed local state from props only at
mount via a lazy useState initializer. The form now owns its values
optimistically; cross-task safety comes from `key={taskId}` on the
parent so the form remounts cleanly when switching tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): Notion-style timezone picker — drop underscores, offset on the right

Underscored labels like 'America/New_York (EST/EDT, UTC-5/-4)' read poorly in
the dropdown. Split each option into `label` (underscore → space) and `offset`,
and render the row with the city on the left and a subtle gray offset on the
right, in line with how Notion's timezone picker presents this.

IANA `value` keeps the underscore so cron and Drizzle stay happy. Search now
filters by the human label only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): keep zone abbreviations in the timezone offset column

Show 'EST/EDT · UTC−5/−4' instead of just 'UTC−5/−4' so users can recognize
the zone by its common abbreviation alongside the offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): drop awkward ':30' suffix from hourly summary

'Every hour:00' / 'Every 2 hours:30' read like glitched concatenations. Cron
storage always rounds to 0 or 30 minutes, so call out the non-zero case as
'at half past' and stay implicit on the top of the hour.

- Every hour
- Every hour at half past
- Every 2 hours
- Every 2 hours at half past

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): collapse advanced settings by default

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  perf(tasks): coalesce post-write refresh and add timezone search

Two follow-up fixes for the AgentTasks scheduler popover.

##### Optimistic schedule writes, single coalesced refresh

Rapid edits in the scheduler form (toggling daily/hourly/weekly, weekday
chips, time, etc.) each triggered `taskService.update` + a full
`internal_refreshTaskDetail` per call. With overlapping requests the
refreshes returned intermediate server state and bounced TaskTriggerTag /
summary text away from the user's latest choice.

- Add `#withCoalescedRefresh` on the task config slice: it tracks a per-task
  pending-writes count and only fires `internal_refreshTaskDetail` after the
  LAST in-flight write settles.
- Give `updateSchedule` an optimistic `internal_dispatchTaskDetail` so
  external readers see the new pattern/timezone/maxExecutions immediately.
- Route both `updateSchedule` and `setAutomationMode` through the coalescer.

##### Timezone picker — search input at the top

The dropdown had antd's implicit type-into-trigger search, which most users
miss. Add a `SearchBar` inside `dropdownRender`, filter the options against
label/value/offset locally, and show an empty state when nothing matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(scheduler): weekday chips only show background when selected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(tasks): dispatch optimistic schedule under nested 'schedule' field

`TaskDetailData` exposes schedule as `schedule.{pattern,timezone,maxExecutions}`,
not flat columns. The previous optimistic dispatch used the DB-style flat keys,
which broke type-check and would never reach the in-memory selectors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks): drop Cmd+Backspace shortcut on the Delete menu item

Header dropdown only advertised the hotkey (no handler), and the right-click
context-menu handler is gone too — keeps the visual claim honest and
removes the irreversible-by-keystroke footgun.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(agent-signal): pin `now` in proposal activity tests to fixture window

Two cases relied on the real system clock; once today crossed the
fixture's default `expiresAt` (2026-05-12), pending proposals were
classified as expired and the assertions broke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks): hide '#' placeholder icon for heterogeneous agent topics

Claude Code / Codex topics aren't chat topics in the usual sense, so the
fallback HashIcon in the sidebar row reads as noise. Skip it when the
current agent has a heterogeneousProvider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🧪 test(tasks): provide agentMap in TopicItem store mock

`isCurrentAgentHeterogeneous` walks through `currentAgentConfig` which
indexes `s.agentMap[agentId]`. Extend the mocked store state to include
an empty `agentMap` so the selector resolves to `undefined` (= not
heterogeneous) instead of throwing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:01:59 +08:00
Arvin Xu c62af095f5 🐛 fix(cli): remove stale cron entry from generated man page (#14709)
* 🐛 fix(cli): remove stale cron entry from generated man page

The cron command was removed from program.ts but the generated man page
still listed it. Regenerated via bun run man:generate.

* 🔖 chore(cli): release 0.0.15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:40:55 +08:00
Arvin Xu 9c746d5784 💄 style(tool): add word wrap toggle to tool arguments display (#14706)
 feat(tool): add word wrap toggle to tool arguments display

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:31:59 +08:00
Rdmclin2 a74cd2bf9f 🐛 fix: sidebar add agent (#14693)
* fix: sidebar add agent and group error

* feat: add billboard cta
2026-05-12 10:27:38 +07:00
Innei 1a368ea823 💄 style(nav): unify ActionIcon sizing and improve TodoList encapsulation (#14692)
- Extract SIDEBAR_HEADER_ACTION_ICON_SIZE constant for consistent sidebar header ActionIcon sizing
- Pass size prop to ToggleLeftPanelButton
- Simplify Agent selector ActionIcon to use 'small' size preset
- Move layout wrapper styles from Body into TodoList root for better component encapsulation
- Increase Nav gap from 1 to 4 for proper spacing
2026-05-12 00:59:13 +08:00
YuTengjing 98156dba8d feat: inline skill auth in recommended task templates (#14676)
*  feat: support refreshing recommended task templates

- Add optional `refreshSeed` through `listDailyRecommend` API, service, and
  client; SWR key includes it so a refresh actually refetches.
- Frontend stores the seed in sessionStorage (via `useSessionStorageState`)
  so a new tab or next day returns to the default daily picks.
- Home Daily Brief shows a "Refresh" affordance on the Recommendations
  subtitle row.
- Fix first-card pinning when matched candidates < RECOMMEND_COUNT: fold
  the fallback pool in so seed reorders the whole batch instead of locking
  position 0 to a single-match template.

Linear: LOBE-8689

*  feat: resolve task-template icon priority

Render the task-template card icon as self > skill provider > interest > Sparkles. Skill icons read required[0] then optional[0], skipping unresolvable providers. URL icons render via @lobehub/ui Image, component icons keep the 28x28 tile.

*  feat: inline skill auth in task template card

Single click "Add task" is now the entire flow: the button stays put, and if a required skill is missing we chain its OAuth popups and create the task automatically. Unauthorized providers (required + optional) appear as compact inline rows above the footer; the provider that already drives the card's main icon is suppressed to avoid duplicating the same logo.

*  feat: add task template detail modal

Open a detail modal when the recommended task template card is clicked,
exposing the full instruction (markdown) plus inline skill auth and the
add-task action. Rename i18n `${id}.prompt` -> `${id}.instruction` to
align with the task table column, and write both `description` and
`instruction` when creating the task. Extract shared `TemplateBriefIcon`,
`useScheduleText`, `useTaskTemplateCreate` and `useVisibleAuthSpecs` so
the card and the modal share the same creation flow and OAuth chaining.

* 🐛 fix: missing Block import in TaskTemplateCard

*  feat: render recommended templates on empty Tasks page

Replace the bare "no tasks" placeholder with a hero landing: greeting,
enlarged inline composer (hero variant), and a 2-column grid of up to
10 recommended task templates. Plumbs a new `count` option through the
service, both routers, the client service, and the recommendations hook
so the home page keeps its 3-card layout while the empty Tasks page
asks for 10.

* 🐛 fix: type cast in resolveTemplateIcon test for unknown interest

* 🌐 i18n: update translations for task template empty-state and other namespaces
2026-05-12 00:28:24 +08:00
Innei 3ef4083dfb 🐛 fix: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop (#14689)
Migrate all ScrollShadow usages to ScrollArea (scrollFade) to eliminate
the effect → setState → render → effect cycle that caused React error
#185 (Maximum update depth exceeded) in the scroll overflow hook.

Affected components:
- StreamingMarkdown
- AgentCouncil AutoScrollShadow
- AssistantGroup ContentBlocksScroll
- Conversation Thinking

Fixes lobehub/lobehub#14650
2026-05-12 00:15:12 +08:00
LiJian a5299696de 🐛 fix(heteroFinish): trigger task lifecycle on cloud sandbox agent completion (#14681)
* 🐛 fix(heteroFinish): trigger task lifecycle transition on sandbox agent completion

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

* 🐛 fix(heteroFinish): guard onTopicComplete against duplicate finish calls

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:31:26 +08:00
LiJian f64c74db90 📝 docs(cloudHeteroContext): add sandbox persistence & gh push rules (#14682)
* 📝 docs(cloudHeteroContext): add sandbox persistence & gh push rules

Inject ephemeral-sandbox warnings and mandatory GitHub push rules into
the cloud CC context block so every Claude Code run knows:
- The sandbox is wiped after inactivity — local changes will be lost
- All code changes must be committed and pushed before task is complete
- Use gh CLI (pre-authenticated) for GitHub operations

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

* 🐛 fix(cloudHeteroContext): address review comments on sandbox persistence rules

- Remove gh push guidance (gh has no push subcommand; git push is correct)
- Gate gh-auth instructions behind githubToken availability to avoid
  auth-dependent commands failing in no-token sandbox runs

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

* 📝 docs(cloudHeteroContext): add git push auth fallback guidance

Tell CC that the sandbox has git credentials ready, but if git push
fails it can self-recover via:
1. gh auth setup-git (reconfigures git credential helper)
2. inline token URL as last resort (oauth2:$GITHUB_TOKEN@github.com)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:21:15 +08:00
YuTengjing 83b2a00314 📝 docs(skills): frontmatter cleanup + argument-hint (#14683)
* 🔨 chore: control skill triggering via frontmatter flags

- Rename debug skill to debug-package (avoid confusion with debugging workflows)
- Add disable-model-invocation to add-* skills so they are manual-only
- Add user-invocable: false to reference/architecture skills so they auto-load only when relevant

* 🔨 chore: rename skill reference dirs to plural references

Align with the skill-creator convention (scripts/, references/, assets/).

* 📝 docs(skills): split oversized SKILL.md files and refine triggers

- upstash-workflow: 1126L → 189L, extract implementation / best-practices / examples references
- data-fetching: 854L → 613L, move parent-keyed-map walkthrough to references
- store-data-structures: 625L → 314L, extract types and reducer references
- upstash-workflow/cloud.md, version-release/release-notes-style.md: add TOCs
- linear: rewrite ALL-CAPS MUSTs into prose explaining why; mark user-invocable: false
- version-release: mark disable-model-invocation: true (manual /version-release only)
- debug-package: expand description with concrete trigger phrases and tokens

* 📝 docs(skills): regularize microcopy structure

Move language-specific guidelines into references/zh.md and references/en.md
so SKILL.md can point to them via the standard progressive-disclosure pattern.
Previously the two files sat next to SKILL.md but were not referenced anywhere,
making them invisible to Claude Code loading.

* 📝 docs(skills): move builtin-tool refs into references subdir

Aligns builtin-tool with the references/ layout used elsewhere
(microcopy, store-data-structures). 3 md files move, SKILL.md
links updated.

* 📝 docs(skills): broaden trigger descriptions for core skills

Adds concrete API names, file paths and natural-language phrases so
auto-triggering catches more relevant prompts. Touches zustand,
drizzle, i18n, react, typescript, modal, hotkey.

* 📝 docs(skills): add argument-hint to user-only skills
2026-05-11 22:48:38 +08:00
𝑾𝒖𝒙𝒉 c0b9124956 🐛 fix(hotkey): remove redundant onClear to prevent double updateHotkey calls (#14663)
Previously, clicking the clear button on HotkeyInput triggered both
`onClear` and `onChange` (since HotkeyInput internally calls
`setHotkeyValue('')` which fires `onChange`). This caused two
concurrent requests to `updateDesktopHotkey` and showed two toast
messages (success/error) for a single user action.

Fix: remove the redundant `onClear` prop. HotkeyInput's clear action
already fires `onChange('')`, so the single `onChange` handler is
sufficient.

Co-authored-by: Innei <i@innei.in>
2026-05-11 22:47:58 +08:00
Innei b794eb1fb9 ♻️ refactor(web-onboarding): merge agent-marketplace identifier into onboarding tool (#14672)
* ♻️ refactor(web-onboarding): merge agent-marketplace identifier into onboarding tool

Drop the standalone `lobe-agent-marketplace` builtin tool and fold its
`showAgentMarketplace` / `submitAgentPick` APIs into `lobe-web-onboarding`
so onboarding exposes a single tool identifier.

- Move marketplace API entries (with humanIntervention/renderDisplayControl)
  into WebOnboardingManifest; extend WebOnboardingApiName.
- Compose AgentMarketplaceExecutionRuntime inside WebOnboardingExecutionRuntime;
  the client WebOnboardingExecutor now owns showAgentMarketplace/submitAgentPick
  with telemetry hooks. Drop the separate client/server executor + runtime files.
- Merge marketplace Inspector / Intervention / Render maps under the
  web-onboarding identifier. Remove AgentMarketplace* entries from
  builtin-tools registries and from the builtin web-onboarding agent's
  plugins list.
- Switch customInteractionHandlers to route by (identifier, apiName) so
  the marketplace picker handler fires only on `showAgentMarketplace`.
- Drop the `lobe-agent-marketplace` fallback string in
  OnboardingActionHintInjector; match by apiName only.
- Rename plugin/setting locale keys under `lobe-web-onboarding.*`.

* 🐛 fix(onboarding): reserve scroll headroom for agent marketplace overlay

- Add a footerSlot spacer in ChatList matching the marketplace panel height so the latest message can be scrolled into view above the absolute overlay.
- Nudge the marketplace overlay inset by 2px to hide subpixel border seams.
- Document turn output order in the onboarding system role to avoid trailing filler text after tool calls.
2026-05-11 21:29:41 +08:00
YuTengjing 5ef0238b22 🐛 fix: reject inactive OIDC access (#14674)
* 🐛 fix: reject inactive OIDC access

* 🐛 fix: honor expired OIDC bans

* 🐛 fix: decouple OIDC inactive error from tRPC

*  test: fix OIDC auth type checks
2026-05-11 21:20:04 +08:00
Arvin Xu dd02ac7062 💄 style(web-onboarding): add Render for saveUserQuestion & showAgentMarketplace (#14667)
 feat(builtin-tool-web-onboarding): add Render for saveUserQuestion + showAgentMarketplace

Tool messages for `saveUserQuestion` and `showAgentMarketplace` previously
fell back to the raw Arguments/Response table once the call resolved
because neither API had a Render registered. Wire both up:

- `saveUserQuestion`: new Render mirroring the Intervention's detail-card
  style — agent identity (emoji + name), full name, and interests chips —
  rendered conditionally per the fields actually saved.
- `showAgentMarketplace`: reuse the existing `SubmitAgentPick` Render.
  After the picker submits, `customInteractionHandlers` rewrites the
  `showAgentMarketplace` tool message's `pluginState` to the same
  `{ summaries, installedAgentIds, ... }` shape, so the card grid
  renders without a new component.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:50:16 +08:00
Arvin Xu ae3dc902e3 ♻️ refactor(knowledge-base): share RAG runtime across client/server via KnowledgeBaseSearchService (#14673)
* ♻️ refactor(knowledge-base): share runtime across client/server via KnowledgeBaseSearchService

Extract a server-side `KnowledgeBaseSearchService` (semanticSearchForChat
fan-out + getFileContents branching + groupAndRankFiles) so both the lambda
chunk router and the builtin tool server runtime orchestrate RAG through one
implementation. Wire the builtin knowledge-base tool to the shared
ExecutionRuntime in the package by moving the client executor to
`src/client/executor/` and registering a thin server runtime factory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(knowledge-base): move PG 23505 handling into adapters, restore executor path

ExecutionRuntime is dual-end so it cannot detect PG error codes — only the
server adapter can. Move the unique-constraint check there and translate the
lambda router's `FILE_ALREADY_IN_KNOWLEDGE_BASE` sentinel in the client
adapter, so the runtime's generic catch surfaces the human-readable message
on both code paths. Restore `src/executor/` as a top-level sibling of
`src/client/` to match the convention of every other builtin tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(knowledge-base): collapse executor into /client, drop ./executor export

The executor is just another client-only adapter (alongside Inspector and
Render) — no reason for it to sit at the package root with a dedicated
subpath. Move it under `src/client/executor/`, re-export from
`src/client/index.ts`, drop the `./executor` entry from package.json, and
update the consumer to import from `@lobechat/builtin-tool-knowledge-base/client`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(knowledge-base): cover KnowledgeBaseSearchService

13 unit tests across both methods:
- getFileContents: docs_* direct read, missing doc, file_* via findByFileId,
  parseFile fallback, parse failure surfaces as error entry, missing file,
  mixed batch.
- semanticSearchForChat: chunk grouping + relevance ranking, BM25 skip when
  no knowledgeIds, knowledgeIds → fileIds expansion, vector/BM25 isolated
  failure capture (preserves the other path's results + structured
  rejections), full failure path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:49:17 +08:00
Arvin Xu 853998b560 ♻️ refactor(bot): close activator bypass + converge device-access checks (#14664)
* ♻️ refactor(aiAgent): introduce deviceToolRegistry as single source of truth

Centralise "what counts as a device tool" into one module so the next
device-tool addition only touches one file. Removes the hardcoded
`new Set(['local-system', 'remote-device'])` from `deviceToolAudit.ts`,
which had drifted from `LocalSystemManifest.identifier` /
`RemoteDeviceManifest.identifier` imports elsewhere.

Foundation for the LOBE-8768 activator-bypass fix landing next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(aiAgent): block activator from bypassing canUseDevice gate

External bot senders could still reach the owner's machine by having the
LLM call `lobe-activator.activateTools(["lobe-remote-device"])`, because
`enableCheckerFactory.allowExplicitActivation` short-circuits before the
canUseDevice rule, and the engine's `manifestSchemas` always contained
the full builtin list (LOBE-8768 B1).

Fix by filtering builtin manifests **physically** through
`buildAllowedBuiltinTools` at both feed-points (ToolsEngine input and
the activator-discovery `toolManifestMap`). When `canUseDevice=false`,
the device manifests no longer exist in either map, so explicit
activation cannot resolve them — the rule-layer gate becomes
defense-in-depth instead of the sole barrier.

Validates with the prod incident's repro path: an external sender's
`<available_tools>` no longer advertises `lobe-remote-device`, and an
activator call to enable it returns "not found".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(bot,messenger): centralise isOwner derivation in buildBotContext

The same fail-closed expression
`!!operatorUserId && senderExternalUserId === operatorUserId` was
duplicated across `BotMessageRouter.onNewMention`, `.onSubscribedMessage`,
the DM catch-all, and `MessengerRouter.dispatchToAgent` — four sites,
one rule, one place to silently regress.

Route all four through `buildBotContext`. The helper now owns the
fail-closed contract referenced by `ChatTopicBotContext.isOwner`'s
docstring, so adding the next platform/router can't accidentally
default to "trusted when in doubt".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(aiAgent): apply device filter post-merge across all manifest sources

The previous fix only filtered the `builtinTools` source. An installed
plugin or a Skill/Klavis manifest declaring
`identifier: 'lobe-remote-device'` would still survive in
`manifestSchemas` and reach `toolManifestMap` via either
`getEnabledPluginManifests` or the direct ingest loops in
`aiAgent/index.ts` — letting an external bot sender activate the device
identifier through the activator.

Two changes close the gap:

  1. `ServerAgentToolsEngineConfig.excludeIdentifiers` — applied **after**
     combining plugin + builtin + additional manifests in
     `createServerToolsEngine`. `createServerAgentToolsEngine` passes
     `DEVICE_TOOL_IDENTIFIERS` whenever `canUseDevice` is false.

  2. `isManifestIngestAllowed` in `aiAgent.execAgent` — a single
     identifier guard reused at every `toolManifestMap` / `toolSourceMap`
     write (engine-returned plugin manifests, lobehub-skill loop,
     klavis loop). New ingest points inherit the wall automatically.

New test pins the regression: a plugin + an additional manifest
spoofing the device identifiers are dropped from `availablePlugins`
when `excludeIdentifiers` is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:45:52 +08:00
Arvin Xu e51c38c182 ♻️ refactor(task): snapshot agent model into task.config at create time (#14670)
*  feat(task): snapshot agent model into task.config at create time

Pin the assignee agent's current model/provider into task.config when a
task is created so later changes to the agent's default model don't
silently affect already-created tasks. On first run, backfill the
snapshot for tasks created before this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task-runner): fall back to inbox agent when task has no assignee

`TaskRunnerService.runTask` previously threw `BAD_REQUEST` for any task
without `assigneeAgentId`, which broke runs created without `--agent`.
Resolve and persist the user's built-in inbox agent instead, surfacing
an `INTERNAL_SERVER_ERROR` only if that resolution itself fails.

Picked from #14671 (closes once landed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(task): collapse router orchestration into TaskService

Move multi-step task verbs out of the TRPC router into `TaskService`:
`createTask`, `cancelTopic`, `deleteTopic`, `runReview`, `updateStatus`,
`previewSubtaskLayers`, `runReadySubtasks`. The router keeps only input
validation + error wrapping; the tool runtime now shares the same
`createTask` path (was duplicating the model snapshot + parent
resolution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🚨 ci: fix tsgo errors from TaskService extraction

`runReadySubtasks` router was rebuilding the `data` payload via a
conditional spread, which forced TS to infer a discriminated union that
broke `result.data.skipped` access in the integration test. Pass the
service result straight through so `skipped` stays a single optional
field. Also cast the stubbed `taskService` in the tool runtime unit
tests to bypass strict structural typing — same pattern the other
dep stubs already use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:21:40 +08:00
YuTengjing 6a66901b12 🔥 chore: drop task template tracking (#14666)
* 🔥 chore: drop task template tracking

The recommendation surface is about to be redesigned, so the analytics
funnel added in #14517 is being removed up front. A fresh tracking
schema will land alongside the redesigned UI.

- Delete `analytics.ts` plus its test and the tracking-focused
  `TaskTemplateCard.test.tsx`.
- Drop `RecommendedTaskTemplate` / `TaskTemplateRecommendationSource` /
  `TaskTemplateFallbackPool` and revert the service to plain
  `TaskTemplate[]`.
- Strip impression, dismiss, create-clicked/result and
  skill-connect-clicked/result calls from `TaskTemplateCard.tsx`, while
  keeping the createTask + navigate-to-task flow from #14540.
- Remove `recommendationBatchId` / `userInterestCount` / `onCreated`
  plumbing from `useDailyBriefRecommendationsUI`,
  `DailyBriefRecommendationsView`, and the card props.
- Revert `useSkillConnection` to the pre-tracking variant (no
  onConnectResult / SkillConnectionResult).

* 🐛 fix: remove created template from recommendation cache

After #14540 changed the create-task flow to auto-navigate to
`/task/{id}`, removing the `onCreated` plumbing from #14517 in the same
sweep meant the SWR recommendation cache was never mutated on success.
Combined with the server-side `recordCreated` being a no-op and
`listDailyRecommend` not excluding created IDs, returning to Home
showed the same recommendation as actionable again — letting users
trigger duplicate scheduled tasks from the same template.

Re-add the minimal cache-eviction plumbing (no analytics):

- TaskTemplateCard exposes `onCreated` and calls it on success
- useDailyBriefRecommendationsUI shares `removeTemplateFromList` for
  both dismiss and created flows
- DailyBriefRecommendationsView passes `onCreated` through
2026-05-11 18:47:45 +08:00
YuTengjing 63c2e251ce 🐛 fix: drop unreachable aihubmix empty-apiKey test (#14669)
* 🐛 fix: drop unreachable aihubmix empty-apiKey test

The `should return empty array when API key is missing` test asserts a
contract that doesn't hold: RouterRuntime.models() constructs the
underlying runtime via the OpenAI-compatible factory before calling
modelsOption, and the factory throws InvalidProviderAPIKey on empty
apiKey at construction time — so aihubmix's own `if (!apiKey) return []`
short-circuit can never actually fire.

Just delete the dead test. The defensive guard in aihubmix's modelsOption
stays as intent documentation. Also tighten an implicit-any in the
adjacent `should normalize model_id field to id` test.

* 🔥 chore: drop dead empty-apiKey guard in aihubmix modelsOption

* 💄 style: tighten aihubmix apiKey assertion to string
2026-05-11 18:44:07 +08:00
Zhijie He dee254c197 💄 style: add reasoning_effort support for Grok 4.3 (#14642)
* style: add reasoning_effort for Grok 4.3

* style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)

style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)

style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)
2026-05-11 17:20:35 +08:00
Arvin Xu 28bf990c88 💄 style: increase chat topic title length (#14659)
* 💄 style: increase chat topic title length

- bump initial topic title slice from 20 to 40 chars
- bump dev fallback slice from 30 to 40 chars
- bump thread title slice from 20 to 40 chars
- raise LLM summary title prompt limit from 50/10w to 80/15w

* 💄 style: bump topic/thread title slice from 40 to 80 chars

Align slice limits with the LLM summary prompt cap (80 chars) so the
initial visible title is no shorter than what the summarizer can return.
2026-05-11 16:32:22 +08:00
Bianzinan f3a785970e fix(aihubmix): use full models endpoint to return complete model list (#14511)
* fix(aihubmix): use full models endpoint to return complete model list

The /v1/models endpoint at api.aihubmix.com returns only per-user-group
models (~256). The new endpoint at aihubmix.com/api/v1/models returns
the complete catalog (800+). Fetch from the full endpoint directly.

* fix(aihubmix): normalize model_id to id from full models endpoint

The https://aihubmix.com/api/v1/models endpoint uses `model_id` instead
of `id`. Map it to `id` before passing to processMultiProviderModelList
to prevent toLowerCase() errors and empty model list.

* fix(aihubmix): add apiKey guard, AbortController timeout, and better error messages

- Extract apiKey with runtime guard to fail fast when key is missing
- Add AbortController with 10s timeout to prevent indefinite hanging
- Include response body in error message for easier debugging
- Add APP-Code header comment pointing to docs
- Expand tests: mock global fetch, cover missing key / HTTP error / network error / AbortError cases

* fix(aihubmix): add field mapping adapter and fix timeout scope

Address review feedback from #14511:

- Update AiHubMixModelCard interface to reflect the new endpoint schema
  with full JSDoc (model_id, desc, types, features, input_modalities,
  context_length, max_output, pricing.cache_read/cache_write)
- Add mapAiHubMixModel() to adapt API response fields to LobeHub model
  card fields before passing to processMultiProviderModelList:
    desc             -> description
    model_name       -> displayName
    context_length   -> contextWindowTokens
    max_output       -> maxOutput
    types            -> type  (llm/t2t->chat, image_generation/t2i->image,
                               video/t2v->video, tts, stt, embedding,
                               rerank/reranking->rerank)
    pricing.cache_read  -> pricing.cachedInput
    pricing.cache_write -> pricing.writeCacheInput
    features(tools/function_calling) -> functionCall
    features(thinking)               -> reasoning
    features(web)                    -> search
    input_modalities(image)          -> vision
- Fix timeout scope: move clearTimeout into the finally block so the
  AbortController stays active during response.json() body read, not
  just during the initial fetch() call
- Update baseURL from https://api.aihubmix.com to https://aihubmix.com
  to match official integration docs (https://docs.aihubmix.com/cn/api/Aihubmix-Integration)
- Strengthen normalize test: assert list.some(m => m.id === 'some-model')
  instead of just Array.isArray to detect normalization failures
- Add field-mapping test using vi.spyOn on processMultiProviderModelList
  to assert that all adapted fields are passed correctly

* fix(aihubmix): filter out unsupported rerank types to prevent chat fallback

- Remove rerank/reranking from TYPE_MAP; they have no LobeHub AiModelType
  equivalent and would silently fall back to 'chat' in processModelCard
- Add UNSUPPORTED_AIHUBMIX_TYPES set and filter before mapAiHubMixModel()
- Add regression test asserting rerank/reranking models are excluded and
  llm models still pass through

---------

Co-authored-by: Bianzinan <bianzinan@users.noreply.github.com>
2026-05-11 16:24:54 +08:00
Innei a238838fea feat(activator): require activation reason (#14597) 2026-05-11 16:23:56 +08:00
Innei 831c2585f1 🐛 fix(onboarding): skip marketplace on early exit, drop CJK in prompts (#14598)
* 🐛 fix(onboarding): skip marketplace on early exit, drop CJK examples in prompts

Honor the user's wish to leave: when the onboarding agent detects a true
early-exit signal in any phase, persist what is known, send a brief
farewell, and call finishOnboarding directly. The marketplace handoff is
mandatory only on normal Phase 4 / Summary completion. Previously the
spec forced the agent to invent categoryHints from environment cues
when discovery was thin, producing noisy recommendations for users who
explicitly asked to stop.

- Replace systemRole §Early Exit with a 4-step flow (no marketplace, no
  summary), and remove the trailing "respect their time" rationale that
  contradicted the new policy.
- Update toolSystemRole turn-protocol exception accordingly; mark
  persistence as best-effort (do not retry on failure) since the
  Pre-Finish Checklist is overridden on early exit.
- Update OnboardingActionHintInjector L101/L127 hints to match the new
  flow, and append an EXCEPTION clause to the Summary not-opened hint
  so a true exit signal in Summary skips the marketplace too.
- Strip CJK example phrases from prompt text; rely on the LLM's
  multilingual recognition with "equivalents in any language" hints.

* 🔨 refactor(FollowUpChips): remove unused consume function and reset editor state on chip click
🔨 style(InterventionBar): remove overflow hidden from container style

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(ci): align FollowUpChips test with removed consume and increase timeout for PGlite cold-start

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-11 15:45:54 +08:00
Neko 79ed4b5faf feat(agent-signal,server,prompts): consolidate in self-review implemented (#14657) 2026-05-11 15:14:02 +08:00
Arvin Xu d4a33d4434 💄 style(hetero-agent): read-only SubAgent threads with breadcrumb header and thread switcher (#14658)
*  feat(hetero-agent): read-only SubAgent threads with breadcrumb header and thread switcher

- Hide chat input on SubAgent threads (execution is driven by the parent agent) and replace it with an inline read-only hint
- Render the hint as the last item inside the virtual list so it scrolls with messages instead of being pinned to the viewport bottom
- ChatList exposes a new `footerSlot` prop that VirtualizedList injects as a synthetic trailing data item
- Header now shows `topic / thread` breadcrumb; thread title is a popover trigger that lists sibling threads in the same topic for one-click switching
- Hide the working-directory tag while inside a thread — directory switching doesn't belong in this read-only view
- Unify user-facing strings to "SubAgent" (badge, hint, open/close labels)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(chat-input): soften queue tray preview borders

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(conversation): scrollToBottom lands on the true last VList item

scrollToBottom targeted displayMessages.length - 1, which leaves any
trailing synthetic items (spacer, SubAgent footer hint) below the
viewport. In SubAgent threads this kept atBottom = false after the
BackBottom click or auto-scroll, so the button appeared stuck.

VirtuaScrollMethods now exposes getTotalCount, which VirtualizedList
fills from the live data length (messages + spacer + optional
footerSlot) via a ref. scrollToBottom uses that to scroll to the real
last index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:42:31 +08:00
Arvin Xu db22573a88 💄 style(chat-input): show skeleton in action bar while config is loading (#14656)
* 💄 style(chat-input): show skeleton in action bar while config is loading

Before agent / group config hydrates, action buttons read DEFAULT_*
fallbacks and the send button would dispatch against a not-yet-ready
target. Add an `isConfigLoading` prop on DesktopChatInput that swaps the
action bar + send area for skeleton placeholders. The chat page passes
`agentSelectors.isAgentConfigLoading`, group chat passes
`agentGroupSelectors.isGroupsInit`. The editor itself stays usable so
users can start typing immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(home,i18n): use 已阅 for brief confirm/confirmDone in zh-CN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): use 确认完成 for brief.action.confirmDone in zh-CN

confirmDone signals the terminal transition (task marked complete),
not just dismissing the brief, so 已阅 loses the semantic distinction
from `confirm`. Use 确认完成 to match the EN intent ("Confirm complete").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): use "Confirm complete" for brief.action.confirmDone in en-US

Match the semantic distinction the call site relies on:
`confirm` is dismiss-only for recurring scheduled runs, while
`confirmDone` marks the terminal completion transition. The test
mock already used "Confirm complete" — align the source defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:56:45 +08:00
Arvin Xu 399db9963a 💄 style(home): add Recommendations module with hetero agent action library (#14645)
*  feat(home): add Recommendations module with hetero agent action library

Introduce a `Recommendations` section that renders above the existing daily-brief
task templates. The module is driven by an extensible action registry with per-action
eligibility checks; the first registered actions surface "Add Claude Code agent" and
"Add Codex agent" cards on desktop when the matching local CLI is detected and the
user hasn't added that hetero agent yet.

- New `src/features/Recommendations/` with action types, registry, hetero-agent
  factory, eligibility hook, parallel CLI detection (SWR-cached) and card UI.
- Extract `createHeterogeneousAgent` from `useCreateMenuItems` into a shared
  `useCreateHeteroAgent` hook so the sidebar menu and Recommendations card share
  one creation path (create + refresh sidebar + navigate to chat).
- `DailyBrief` now renders `<Recommendations />` in place of the standalone
  template-only section; visibility is driven by the new
  `useRecommendationsVisible` hook.
- Add `recommendations.*` i18n keys to the `home` namespace (default + zh-CN +
  en-US dev preview).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(home): polish Recommendations card with brand avatar and tighter copy

Use brand Avatar icons with rounded square shape, drop the duplicate title, and tighten copy (Coding Agent tag, Add Agent CTA).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:18:55 +08:00
Rdmclin2 d5562f9933 🔨 chore: optimize system bot (#14649)
* feat: add already consumed alert

* feat: support slack send slack commends  emphemeral in channel

* chore: handle parse commands imperial

* fix: slack messenger callback ok

* feat: add messager connectionId per user

* fix: add userId to webhookbody

* fix: test case
2026-05-11 02:02:33 +07:00
Arvin Xu 5f24d179d4 feat(hetero-agent): support AskUserQuestion tools for claude code (#14639)
*  feat(hetero-agent): AskUserQuestion MCP server + bridge skeleton (LOBE-8725 step 1+2)

Foundation for LOBE-8725 — interactive AskUserQuestion via local MCP. CC's
built-in tool short-circuits in `-p` mode, so we host an in-process MCP
server that exposes an equivalent `ask_user_question` tool. The handler
blocks until the consumer submits an answer (or the 5min deadline / op
shutdown fires), surfacing a structured `agent_intervention_request` /
`agent_intervention_response` round-trip on the existing event stream.

Added in this commit:

- `packages/heterogeneous-agents/src/askUser/`
  - `AskUserBridge` — per-op pending map with timeout / cancel / progress
    keepalive support; emits an async-iterable of outbound events
  - `AskUserMcpServer` — process-wide HTTP/Streamable MCP server,
    `?op=<id>` query routes via `AsyncLocalStorage` →
    `onsessioninitialized` → sessionId↔opId map; tool handler hands off
    to the matching bridge and pumps `notifications/progress` back to CC
    every 30s as wire-level keepalive (required for >5min waits, see
    spike notes)
  - `constants.ts` — shared tool/server names + the stable `apiName`
    the adapter rewrites to
  - Unit tests cover bridge lifecycle (resolve / cancel / timeout /
    progress / event stream) and an end-to-end MCP probe via
    `StreamableHTTPClientTransport`

- `packages/agent-gateway-client/src/types.ts` — wire-level
  `agent_intervention_request` / `agent_intervention_response` event
  variants + payload interfaces. Re-exported through the package barrel.

- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` — when CC's
  `tool_use` carries `mcp__lobe_cc__ask_user_question`, the adapter
  rewrites `apiName` to `askUserQuestion` so the renderer routes on a
  clean domain key. Identifier stays `claude-code`. Applied to both the
  main-agent and subagent paths for symmetry (subagent ask isn't
  expected today, but doesn't hurt).

- `src/server/routers/lambda/aiAgent.ts` — Zod input schema for
  `aiAgent.heteroIngest` extended with the two new event types so the
  CLI sandbox can forward them through the server.

No producer wiring yet — Steps 3-5 plug this into Electron main, the
renderer executor, and the new UI.

*  feat(hetero-agent): wire AskUserQuestion MCP into Electron CC driver (LOBE-8725 step 3)

Plug the Step 1 skeleton (`AskUserMcpServer` + `AskUserBridge`) into the
desktop Claude Code spawn path. CC's local MCP `ask_user_question` tool now
goes live during real prompts; renderer-submitted answers route back via
new IPC.

Changes
- `apps/desktop/src/main/modules/heterogeneousAgent/types.ts` — add
  optional `mcpConfigPath` to `HeterogeneousAgentBuildPlanParams` so
  controller-managed temp configs flow into the driver.
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`
  — append `--mcp-config <path>` when provided. Disallowed-tools pin
  stays so CC's built-in AskUserQuestion remains off (avoids double-
  registration of the same tool name).
- `apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
  - Lazy-singleton `AskUserMcpServer` started on first claude-code prompt
    (de-duped concurrent first-callers via in-flight promise).
  - Per-op `setupInterventionForOp(opId, sessionId)`: registers an
    `AskUserBridge`, writes `os.tmpdir()/lobe-cc-mcp-<opId>.json` with
    `alwaysLoad: true` so CC eager-loads the tool (1-hop call, no
    ToolSearch detour — see LOBE-8725 spike), pumps `bridge.events()`
    into the existing `heteroAgentEvent` broadcast.
  - Cleanup paths: exit handler `await intervention.cleanup()` settles
    pending MCP handlers + unlinks the temp config; pre-spawn errors
    short-circuit the same cleanup so we don't leak bridges on
    `buildSpawnPlan` / trace-session failures.
  - `before-quit` stops the MCP server (in addition to killing CC
    processes).
  - New `@IpcMethod() submitIntervention({ operationId, toolCallId,
    result?, cancelled?, cancelReason? })` — renderer side will dispatch
    answers / cancellations through this in Step 4/5.
  - codex unchanged — bridge setup is gated on `agentType === 'claude-code'`.
- `src/services/electron/heterogeneousAgent.ts` — renderer-side proxy
  for `submitIntervention`.
- New `claudeCode.test.ts` covers the four driver-arg paths
  (`--mcp-config` presence, ordering vs `--resume`, AskUserQuestion stay
  disallowed). Existing 28 controller tests still pass.

What still doesn't run end-to-end
- The renderer `heteroExecutor` doesn't consume `agent_intervention_request`
  yet — events go through the broadcast but the chat store ignores them.
- No UI to render the intervention card or to call `submitIntervention`.
Both lands in Steps 4/5 next.

*  feat(hetero-agent): correlate intervention with tool message + renderer handler (LOBE-8725 step 3.5+4)

Bridge now uses the caller-supplied toolCallId (CC's `claudecode/toolUseId`
from MCP `_meta`) instead of a random UUID, so the
`agent_intervention_request` event references the same id as the existing
tool message on the renderer side.

Renderer-side `heteroExecutor` learns the new event:

- Added `persistInterventionRequest(...)` next to `persistToolResult` —
  stamps `pluginState.askUserQuestion` (apiName + identifier + questions
  parsed from `arguments` + deadline + status='pending' + toolCallId)
  onto the matching tool message via `messageService.updateToolMessage`.
- New branch in `handleStreamEvent` for `'agent_intervention_request'`:
  defers behind `persistQueue` (so it lands AFTER `persistToolBatch`
  populates `toolMsgIdByCallId`), then mirrors the same pluginState onto
  the in-memory message via `internal_dispatchMessage` so the UI lights
  up immediately — no fetchAndReplaceMessages round-trip needed.
- The eventual `tool_result` for the same toolCallId hits the existing
  `tool_result` branch unchanged: it overwrites `pluginState` with
  whatever the result carries (typically undefined for our MCP tool, so
  `pluginState.askUserQuestion` clears and the intervention UI yields to
  the regular Render).

Bridge tests cover the new contract:
- caller-supplied toolCallId becomes the wire correlation key
- duplicate-toolCallId pendings reject loudly so two-handler clobbers
  surface immediately

153 package tests + 1167 desktop main tests + 51 hetero executor tests
still green; type-check clean.

*  feat(claude-code): AskUserQuestion intervention render component (LOBE-8725 step 5)

Dedicated Render for the synthetic `askUserQuestion` apiName the adapter
rewrites the local MCP `mcp__lobe_cc__ask_user_question` tool to. Lives
under CC's render registry so the existing chat tool-detail flow picks
it up automatically — no changes to the conversation framework.

- New `AskUserQuestionItem` / `AskUserQuestionArgs` /
  `AskUserQuestionPluginState` types (mirrors CC's own
  AskUserQuestion schema verbatim).
- `ClaudeCodeApiName` gains an `AskUserQuestion = 'askUserQuestion'`
  member so the renders / inspectors / streamings registries can key
  off the same enum value.
- `client/Render/AskUserQuestion/index.tsx` is the component:
  - `pluginState.askUserQuestion?.status === 'pending'` → renders the
    questions form (Select for single-select, CheckboxGroup for
    multi-select), a 5-min countdown ticking once a second, Submit /
    Skip buttons. Reads `operationId` via `messageOperationMap` so we
    can route through `heterogeneousAgentService.submitIntervention`.
  - Otherwise → renders the questions as muted captions plus the
    final answer text from `content`. Surfaces a warning when the
    tool_result was an error (timeout / cancelled / session ended).
  - Submit button stays disabled until every question has a
    selection; Skip always enabled (sends `cancelled: true`).
- `ClaudeCodeRenders[ClaudeCodeApiName.AskUserQuestion]` registers
  the new component.

What this does NOT do
- Doesn't touch `BuiltinToolInterventions` — the form is rendered
  inside the regular tool body (Render slot), not the canonical
  intervention slot. Cleanest for now: the framework intervention
  flow assumes `submitToolInteraction` store actions, which would
  fight our IPC path. We can refactor onto that surface later if
  CC grows additional interactions (approval, file picker).
- Doesn't translate strings — i18n in a follow-up.

Type-check clean. Step 6 (real desktop e2e via CC) is next.

*  feat(claude-code): render AskUserQuestion form during pending state (LOBE-8725 step 5 follow-up)

Step 5 registered the Render component but stopped at the registry — the
chat tool-detail still returned the loading placeholder while
`isToolCalling` was true, so users only ever saw a spinner during the 5
min intervention window.

Detect `pluginState.askUserQuestion?.status === 'pending'` (only set on
CC + apiName=askUserQuestion tool messages) and route to the registered
builtin Render inline before the placeholder branch. Once the
intervention resolves, the eventual `tool_result` clears
`pluginState.askUserQuestion` and the regular Render takes over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(hetero-agent): wire regenerate / continue for hetero runtime (LOBE-8519 follow-up)

LOBE-8519 left two TODOs in `generationSlice` where hetero runtime
silently fell through to client mode — regenerate would secretly hit the
agent's underlying LLM, and continue would synthesize a fake "please
continue" turn that confuses CC / Codex.

- regenerateMessage: re-create the assistant row branched off the same
  user message, resolve resume sessionId (drop on cwd mismatch), then
  spawn a child `execHeterogeneousAgent` op so Stop only kills the
  executor, not the parent regenerate op. Mirrors sendMessage's hetero
  branch.
- continueGenerationMessage: hetero CLIs have no continue primitive —
  each prompt is a fresh user turn — so bail out instead of polluting
  the session.
- continueGenerationMessage: gateway mode now branches a server-side
  resume run instead of falling through to client.

Surfaced while testing CC AskUserQuestion end-to-end on the
LOBE-8725 branch (regenerating after an answered question went through
the wrong runtime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(local-testing): electron-dev.sh boots on macOS bash 3.2

Two bugs surfaced when invoking the local-testing helper from a fresh
session on macOS:

- `find_project_pids` / `do_stop` end with `grep -v '^$'` whose exit
  code propagates through `pipefail`. With `set -e`, an empty pid set
  silently kills the whole script — `do_start` reported success, no
  Electron, no error. Trail with `|| true`.
- `setsid` is GNU coreutils, not on macOS. Fall back to plain `bash -c`;
  process-tree teardown still works because `expand_descendants` walks
  the tree directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): per-session MCP transport for sequential ops (LOBE-8725)

`AskUserMcpServer` shared a single `StreamableHTTPServerTransport` across
every CC subprocess. The SDK transport latches `_initialized=true`
after the first `initialize`, so the second op's CC subprocess sees
`Invalid Request: Server already initialized` (400) and reports the
`lobe_cc` server as `failed`. From the model's POV the MCP tool is
absent — it falls back to ToolSearch, can't find anything, and
verbalizes the question instead.

Refactor to the canonical multi-tenant pattern: one transport + one
`McpServer` per session, looked up by the SDK-managed `mcp-session-id`
header. New transports are minted on the first POST without a session
id (must be an `initialize` request); subsequent requests route via
the stored map; `onsessionclosed` cleans up.

The first run of any process still works as before — this only matters
once a second op spins up. Added a 3-op sequential regression test
that fails on the old single-transport implementation and passes now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(claude-code): move AskUserQuestion onto canonical Intervention surface (LOBE-8725)

Step 5's first cut shoehorned the pending form into the Render slot and
drove submit/skip with a custom `pluginState.askUserQuestion.status`
field, which forced three layers of glue:

- `Tool/Detail` had to bypass the loading placeholder via an
  identifier+apiName hardcode so the form would surface during
  `isToolCalling`
- The executor had to `messageService.getMessages → replaceMessages`
  after `agent_intervention_request` to drag the freshly-created tool
  row into in-memory state (the framework's own `tool_end →
  fetchAndReplaceMessages` only fires after the user answers)
- The executor also had to `associateMessageWithOperation` for the tool
  row so the form could look up the running CC op for IPC

All three were patches around skipping the canonical surface. This
commit moves AskUserQuestion onto `pluginIntervention.status='pending'`
and the `BuiltinToolInterventions` registry, which the framework
already drives end-to-end:

- `packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion.tsx`
  — pure form, no IPC, no store reads. Resolves through the standard
  `onInteractionAction({type:'submit'|'skip'|'cancel'})` callback.
- `Render/AskUserQuestion` shrinks to the answered/aborted view only;
  the framework hides Render while pending, so no status switching.
- New `Inspector/AskUserQuestion` shows a compact "askUserQuestion · {header}"
  chip in the inline tool body, matching the rest of CC's tools.
- Registries: `ClaudeCodeInspectors`, `ClaudeCodeRenders`, and the new
  `ClaudeCodeInterventions` all key off `ClaudeCodeApiName.AskUserQuestion`;
  `BuiltinToolInterventions` gains a `[ClaudeCodeIdentifier]` entry.

Hetero needs a different action handler than `submitToolInteraction`
(which spawns `executeClientAgent` — wrong for a CC subprocess that's
already blocked on an MCP call). Two thin pieces wire that:

- `submitHeteroIntervention` (chat store) — sets
  `pluginIntervention` via `optimisticUpdateMessagePlugin` (which
  already syncs DB + in-memory + parent-assistant `tools[].intervention`
  in one shot), then forwards the answer through
  `heterogeneousAgentService.submitIntervention` IPC. Operation lookup
  walks the tool message's `parentId` to hit the assistant's
  `messageOperationMap` entry — drops the explicit
  `associateMessageWithOperation` call from the executor.
- `customInteractionHandlers.isHeteroInteractionIdentifier` flags
  `ClaudeCodeIdentifier`; `Tool/Detail/Intervention` short-circuits
  there before reaching the existing `submitToolInteraction` path.

Executor change collapses to one line:
`optimisticUpdateMessagePlugin(toolMsgId, { intervention: { status: 'pending' } })`.
The post-intervention refresh, the associate call, and the
`persistInterventionRequest` helper all go away.

Removed:
- `AskUserQuestionPluginState` type (custom field is gone)
- `Tool/Detail` `askUserPending` inline-render branch
- Executor `messageService.getMessages + replaceMessages` round-trip
- Executor `associateMessageWithOperation` for tool rows
- `persistInterventionRequest` helper

Verified end-to-end against a real CC subprocess on desktop:
- Inline body shows the new Inspector chip; pending form lives in the
  bottom InterventionBar (canonical surface)
- Submit ships answer through MCP, CC continues with structured result
- Skip flips status to `rejected`, framework's RejectedResponse
  shows "User skipped"; CC receives isError and falls back to text
- `mcp_servers.lobe_cc.status === 'connected'` on a 3rd sequential op
  (the per-session transport fix from the previous commit)
- `alwaysLoad: true` still produces 1-hop calls (no ToolSearch hop)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(claude-code): inline numbered option cards for AskUserQuestion intervention (LOBE-8725)

Select dropdown was the wrong primitive — it hides options behind an extra
click and doesn't read like a question to answer. CC's underlying tool is
1-4 questions × 2-4 options, so the whole option set always fits inline.

- Each option renders as a clickable card: numbered chip (1/2/3/4) +
  bold label + secondary description on a single row. Hover tints the
  background; selected state lights up `colorPrimary` on both the chip
  and the card outline so the pick is unmistakable at a glance.
- Multi-select (`q.multiSelect`) toggles instead of replacing, with a
  "(multi-select)" hint in the question header.
- Multi-question support gets a proper visual hierarchy: each question
  past the first sits below a dashed divider, headed by a `Q1/N` tag
  + the original `q.header` chip. The `Q*/N` lets the user track
  progress without counting.
- Inspector picks up the question count too: now shows
  "askUserQuestion · {first header} +N" when multiple are queued.

Verified end-to-end on desktop with a CC-driven 2-question prompt
(4-option + 3-option). Both selections feed back to CC as a single
"User answers" payload, CC echoes both picks in its continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(claude-code): tabbed multi-question + draft + timeout fallback for AskUserQuestion (LOBE-8725)

- Multi-question forms now use a top tab strip; single question renders inline.
- Picking a single-select option auto-advances to the next unanswered question.
- Drafts persist to tool message `pluginState.askUserDraft` so picks survive
  remount / HMR; new `setInterventionDraft` action on the chat store dispatches
  the pluginState patch.
- Timeout fallback: when the 5-min countdown expires, auto-submit option 1 for
  every unanswered question instead of letting the bridge time out into a
  cancelled isError — model gets a structured answer it can act on.
- Visual: selected option now uses filled `colorPrimaryBg` + right-aligned
  check icon; index chip stays neutral.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): synchronously unlink temp mcp.json on app quit (LOBE-8725)

The async exit-handler cleanup raced Electron's main-process teardown and
left `lobe-cc-mcp-<opId>.json` files in `os.tmpdir()` after every quit. Sync
unlink in the quit hook is the only reliable guarantee.

Also handle SIGTERM / SIGINT — `before-quit` only fires on user-driven Cmd+Q
or `app.quit()`, not on external kills (test harness, OS shutdown).

Verified by manual test: pending askUserQuestion forms now leave zero
residue after both Cmd+Q and SIGTERM paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(claude-code): persist structured AskUserQuestion answers + Q&A render (LOBE-8725)

Submit now writes the structured `{ questionText: pickedLabel(s) }` payload
to the tool message's `pluginState.askUserAnswers` (in-memory + DB merge), so
Render no longer has to scrape the bridge's prose `User answers:` content.

Render shows one Q&A block per question — header + question + a checkmark
card per picked option (multi-select fans out into multiple rows). Falls
back to a `—` placeholder when answers are missing (older messages or
skipped flows), and keeps the existing `pluginError` warning for cancel /
no-answer paths.

Also surfaces the answers in the Skill state inspector tab, which was
previously empty for completed askUserQuestion messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(hetero-agent): cover synchronous quit cleanup of AskUserQuestion temp configs (LOBE-8725)

Locks down the regression fixed in c0de0cdb7c — async exit-handler cleanup
losing to Electron's main-process teardown. Four cases: `before-quit`
(Cmd+Q / `app.quit()` path), `SIGTERM` (test harness / OS shutdown),
`SIGINT` (Ctrl-C), and idempotency (already-deleted temp file must not
throw on the second pass).

`process.on` and `process.exit` are stubbed in the signal-path tests so the
controller's listener attaches to a spy, not the test runner's process —
otherwise we'd leak a real SIGTERM listener every test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:16:24 +08:00
Neko ccc8ee1315 ️ perf(agent-signal,prompts,types,database,server): fixed many minor self-review issues, harden the structure, verified with eval (#14647) 2026-05-11 00:46:30 +08:00
Arvin Xu 07eef8e7d9 💄 style(copyable-label): wrap long tool-call params instead of truncating (#14640)
* 💄 style(copyable-label): wrap long values instead of truncating

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(copyable-label): make wrap an opt-in via Descriptions prop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(descriptions): omit GridProps wrap to avoid type collision

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:29:47 +08:00
Arvin Xu ca59baa814 💄 style: format tool execution time as Xmin Ys instead of X.Y min (#14641)
🐛 fix: format tool execution time as `Xmin Ys` instead of `X.Y min`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:28:44 +08:00
Arvin Xu 0f9b6904fd 🐛 fix(model-runtime): enrich stream parse errors with provider/model context (#14636)
*  feat(model-runtime): enrich stream parse errors with provider/model context

When the OpenAI / Anthropic SDK iterator throws (most often a JSON
SyntaxError on a malformed SSE chunk — e.g. an upstream response with an
illegal backslash escape), `convertIterableToStream` previously only
surfaced `message`/`name`/`stack`. Downstream error logs (agent-gateway
errors table) end up with just "Bad escaped character in JSON at
position 160050" and no way to correlate which provider/model produced
it or whether the same offset keeps recurring.

This change threads optional `{ provider, model }` context through
`convertIterableToStream` / `readableFromAsyncIterable` and enriches the
FIRST_CHUNK_ERROR payload with:

- `provider` / `model` so triage can group identical upstream failures
- `parsePosition` extracted from V8 JSON SyntaxError messages
- `causeName` / `causeMessage` when `error.cause` is set (many wrapped
  errors carry the actionable detail in `cause` and the bare triplet
  drops it)

Threaded through OpenAI/Responses/Anthropic stream handlers, which all
already receive `payload` containing provider/model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(model-runtime): walk error.cause for parsePosition + JSON-safe payload

Two review findings on #14636:

1. Wrapped SyntaxErrors lost their parsePosition. Provider SDKs commonly
   rethrow `JSON.parse` failures wrapped in their own error class
   (e.g. `APIError(cause: SyntaxError)`), so the outer `error.name` is
   no longer `'SyntaxError'` and the previous check skipped extraction
   for the exact case this enrichment was meant to diagnose. Now
   `extractParsePosition` walks both the outer error and any `Error`
   cause, and accepts any error whose message still carries the
   `"JSON at position N"` signature even if the SyntaxError name was
   lost in wrapping.

2. Cause cloning could blow up the entire diagnostic path.
   `structuredClone` succeeds on values that `JSON.stringify` later
   throws on (BigInt, circular refs), so a non-Error cause carrying
   either would surface as `payload.cause = clonedObject`, then the
   outer `JSON.stringify(payload)` would throw inside the catch handler,
   and the FIRST_CHUNK_ERROR chunk never gets emitted. Replaced with
   `safeJsonStringify` (BigInt → string, cycles → `[Circular]`) and
   route the cause object through `toJsonSafe` so the returned shape is
   always plain JSON.

Added tests for both: a wrapped APIError(cause: SyntaxError) yields
parsePosition, and a cause containing both BigInt and a circular ref
still emits a parseable error chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:23 +08:00
Arvin Xu a9f41c2217 🐛 fix(home): strip markdown links from daily-brief input placeholder (#14635)
The daily-brief hint will start carrying `[name](url)` markdown links so
the AI can resolve referenced entities when the user submits via the
hint. The placeholder layer is the only consumer that wants the visible
label without the link syntax — extract a small `stripMarkdownLinks`
util and apply it at `InputArea/index.tsx` only. `useSend` continues to
forward the raw hint, so the agent still receives the link in the
outgoing message.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:28:10 +08:00
YuTengjing 80916c05d9 🐛 fix: consume visual content parts in server runtime (#14637) 2026-05-10 18:33:30 +08:00
Arvin Xu 2615c00480 feat(bot): gate device tools by sender identity (#14634)
*  feat(bot): gate device tools by sender identity (LOBE-8715)

External users who @-mentioned a bot ran the agent as the bot owner and
could call LocalSystem / RemoteDevice tools — a confused-deputy hole that
let any group member indirectly read/write the owner's machine.

- `ChatTopicBotContext` carries `senderExternalUserId` + `isOwner`
- `BotMessageRouter` / `MessengerRouter` compute `isOwner` at the entry
  point (fail-closed when `settings.userId` is missing)
- `resolveDeviceAccessPolicy` maps sender identity to
  `{ canUseDevice, reason }`; trusted-list branch is reserved for future
  work without engine changes
- `AgentToolsEngine` gates `LocalSystem` + `RemoteDevice` on `canUseDevice`
- `RemoteDeviceManifest.systemRole` is no longer injected on
  external-sender turns — closes the device-list information leak
- Per-call audit log (`lobe-server:agent-device-tool-audit`) at the
  dispatch site records sender, isOwner, reason, identifier, apiName

Fixes LOBE-8715

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🚨 chore(bot): replace `any` on botContext / botPlatformContext with concrete types

Picks up the existing `BotPlatformContext` (`@lobechat/context-engine`)
and `ChatTopicBotContext` (`@lobechat/types`) — both already exported —
instead of the inherited `any` placeholders on:

- `OperationCreationParams.{botContext, botPlatformContext, deviceAccessPolicy}`
- `InternalExecAgentParams.botPlatformContext`
- `RuntimeExecutorContext.botPlatformContext`

`deviceAccessPolicy.reason` is now `DeviceAccessReason` instead of `string`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔒 fix(bot): clear activeDeviceId when canUseDevice=false (LOBE-8715)

The previous patch gated `LocalSystemManifest` in the engine's enabledToolIds,
but `buildStepToolDelta` re-injects local-system from `state.metadata.activeDeviceId`
on every step regardless of whether the engine excluded it. Auto-activation
in `aiAgent.execAgent` populated `activeDeviceId` whenever
`(discordContext || botContext) && onlineDevices.length === 1`, so an
external bot sender with one device online could still get local-system
tools against the owner's device.

- `aiAgent/index.ts`: skip `activeDeviceId` derivation entirely when
  `canUseDevice` is false. `deviceSystemInfo` short-circuits naturally on
  `if (activeDeviceId) {...}`, so no extra change needed there.
- `RuntimeExecutors.ts`: belt-and-suspenders — if
  `state.metadata.deviceAccessPolicy.canUseDevice` is false, swallow
  `activeDeviceId` before passing to `buildStepToolDelta`, so a future
  plumbing bug at the source can't reopen the bypass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔒 feat(bot): allow device tools on personal-scope platforms (WeChat) (LOBE-8715)

Not every bot platform can identify an owner. WeChat's LobeHub integration
encodes every inbound thread as 1:1 (`packages/chat-adapter-wechat/src/adapter.ts:465`)
and its settings schema has no `userId` field, so `isOwner` is structurally
false on every WeChat turn. The previous policy denied every WeChat call
with `bot-owner-not-configured` — fail-closed but unusable.

This commit treats platforms whose integration is structurally personal-
scope as trusted. WeChat is the only member today; LINE is intentionally
excluded because its adapter handles group/room threads even though its
schema also lacks `userId` — those must be fixed at the schema layer
before being whitelisted.

- New `bot-personal-platform` reason in `DeviceAccessReason`
- `PERSONAL_SCOPE_BOT_PLATFORMS = new Set(['wechat'])`
- Personal-scope check sits AFTER `isOwner` so a future WeChat schema
  with a `userId` field still resolves as the more specific `bot-owner`
- Tests: WeChat without isOwner → allow; WeChat with isOwner=true → still
  `bot-owner` (more specific wins); regression guard ensuring Discord /
  Slack / Telegram / Feishu / Lark / QQ / LINE keep going through the
  standard isOwner gate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(engine): opt existing device gate tests into canUseDevice=true (LOBE-8715)

The `LocalSystem` / `RemoteDevice` enable rules now short-circuit on
`canUseDevice` (default `false`), so tests that exercise the
engine-internal gates (`runtimeMode`, `deviceContext`, `clientRuntime`)
must explicitly pass `canUseDevice: true` — otherwise they assert the
right behavior for the wrong reason or fail outright (e.g. the desktop
RemoteDevice-suppression case the reviewer flagged).

- All `LocalSystem` / `RemoteDevice` / `LocalSystem + RemoteDevice` /
  `clientRuntime === "desktop" (Phase 6.4)` blocks now set
  `canUseDevice: true`.
- The "disable RemoteDevice in bot conversations" test was repurposed:
  the dropped `!isBotConversation` clause is now subsumed by `canUseDevice`,
  so for a trusted bot caller (canUseDevice=true) RemoteDevice DOES surface.
  The original intent — block when caller is untrusted — is captured in
  the new `canUseDevice gate` block.
- New `canUseDevice gate` describe block asserts:
    1. `canUseDevice=false` blocks LocalSystem even on a desktop caller
    2. `canUseDevice=false` blocks RemoteDevice with proxy configured
    3. Omitting `canUseDevice` → fail-closed default (deny)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(execAgent): set isOwner=true on device auto-activation tests (LOBE-8715)

These pre-existing tests model an owner using the bot through Discord and
assert that `activeDeviceId` auto-populates when one device is online.
After LOBE-8715, `activeDeviceId` is gated on `canUseDevice` from
`resolveDeviceAccessPolicy`, so a `botContext` without `isOwner: true`
resolves to `bot-external-sender` → `canUseDevice=false` →
`activeDeviceId=undefined`.

Filling out the `botContext` mocks with `isOwner: true` (plus the other
required fields the type now demands) preserves the tests' original
intent while exercising the new gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:44:56 +08:00
YuTengjing 58318e97df 🐛 fix: store onboarding interests as keys (#14624) 2026-05-10 16:44:22 +08:00
Arvin Xu 4b8105b8b2 🔥 chore(web-crawler): remove WeChat URL rules (#14633)
Drop the `weixin.sogou.com` and `mp.weixin.qq.com` rules from the crawler
URL ruleset since they are no longer needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:28:53 +08:00
LobeHub Bot 2a65f81f0d 🌐 chore: translate non-English strings to English in apps/cli, apps/device-gateway, and apps/desktop scripts (#14626)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:04:17 +08:00
LiJian 1d2f0dcdb9 🐛 fix(hetero-agent): sync new-step assistant across replicas (#14631)
* 🐛 fix(hetero-agent): sync new-step assistant across replicas

* 🐛 fix(hetero-agent): tighten new-step assistant fallback

* fix: slove the test
2026-05-10 14:05:20 +08:00
LiJian 2098ac8374 🐛 fix: remove the old cron job from lobehub (#14630)
* fix: remove the old cron job from lobehub

* fix: add some ts back
2026-05-10 13:49:32 +08:00
LiJian cfe618fb50 🐛 fix: refresh content baseline from DB on every ingest call (#14603)
* 🐛 fix: refresh content baseline from DB on every ingest call

Vercel serverless routes consecutive batches to different Lambda
instances. A warm replica's in-memory `accumulatedContent` only
reflects batches it processed; it has no visibility into batches
handled by other replicas.

The failure pattern (worst when a repo is selected, since CC makes
tool calls early):

1. Lambda A — batch 1 (text "你好!...") → flushBatchContent writes
2. Lambda B — batch 2 (text "...任务。") → restores from DB, appends,
   writes longer text to DB
3. Lambda A — batch 3 (tools_calling only, warm state) → its stale
   `accumulatedContent` = batch-1 text → persistMainToolBatch Phase 1
   writes `{ tools, content: stale-short-text }` → OVERWRITES the
   correct longer DB value → content truncated at "你"

Fix: re-read the current assistant message from DB at the start of
every `ingest()` call. Since `flushBatchContent` writes at the end of
every batch, DB is authoritative. The refresh gives each Lambda the
latest flushed baseline, so new text in the current batch extends
the correct full string.

Cost: one extra `findById` round-trip per warm ingest call.

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

*  feat: auto-inject GitHub OAuth token into CC sandbox

Previously the GitHub token was only resolved when repos were selected
AND GITHUB_CRED_KEY was explicitly configured in the agent config —
so CC running without pre-selected repos had no GitHub access and had
to ask the user for a PAT manually.

Changes:
- aiAgent/index.ts: always try to resolve the token using key 'github'
  (standard LobeHub OAuth connector default); GITHUB_CRED_KEY still
  overrides. No longer guarded behind topicRepos.length > 0.
- sandboxRunner.ts: new buildCredsSetupScript() runs before CC starts:
    mkdir -p ~/.creds
    printf 'GITHUB_ACCESS_TOKEN=%s\n' <token> > ~/.creds/env
    gh auth login --hostname github.com --with-token
  Writes ~/.creds/env in the same format as injectCredsToSandbox(["github"])
  so CC can source it in sub-shells. Creds step runs before repo clone step.
- cloudHeteroContext.ts: system prompt now tells CC that GITHUB_TOKEN is
  set, gh CLI is pre-authenticated, and ~/.creds/env has GITHUB_ACCESS_TOKEN
  with the source/auth recipe for sub-shell usage.

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

* 🐛 fix: adopt max-length content on DB refresh to guard flushBatch retry

The unconditional DB overwrite in ingest() broke the retry contract:
if flushBatchContent threw after events were already marked in
processedKeys, a retry on the same warm instance would read the stale
(shorter) DB value and wipe the in-memory chunks — which processedKeys
would then skip, losing them permanently.

Fix: only adopt the DB value when it is LONGER than in-memory.
This preserves both behaviours:
- Multi-replica stale (the original fix): DB has more content from
  another replica → dbContent.length > in-memory → adopt DB. ✓
- flushBatchContent retry on same Lambda: DB still has the old shorter
  value, in-memory has the correct accumulation → keep in-memory. ✓

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:22:37 +08:00
Arvin Xu e3cace359b 🐛 fix(hetero-agent): disable Claude Code AskUserQuestion to avoid auto-decline (#14629)
* 🐛 fix(hetero-agent): disable Claude Code AskUserQuestion to avoid auto-decline

CC's built-in AskUserQuestion self-injects an `is_error: "Answer questions?"`
tool_result inside the CLI in `-p` non-interactive mode before the host can
surface the questions, so the model falls back to plain-text prompting after
a wasted round-trip. Add `--disallowedTools AskUserQuestion` to both spawn
sites (desktop driver + lh hetero exec) so the model goes straight to text.

To be revisited once a local MCP-backed replacement is wired to LobeHub's
intervention UI.

* ♻️ refactor(hetero-agent): share CC base args, opt-in partial deltas

- Promote CLAUDE_CODE_BASE_ARGS in `@lobechat/heterogeneous-agents/spawn` to
  the canonical source of truth for invariant CC CLI flags (`-p`, stream-json
  IO, `--verbose`, `--disallowedTools AskUserQuestion`); export it so the
  desktop driver can compose on top instead of duplicating.
- Pull `--include-partial-messages` out of the base. It's now a
  `SpawnAgentOptions.includePartialMessages` flag, off by default so
  `lh hetero exec` standalone/sandbox runs don't pay for delta noise they
  don't render. The desktop driver opts in (chat bubble streams live).
- Permission mode stays caller-specific: desktop hardcodes bypassPermissions
  (always user-mode), the package keeps its root-vs-user branch for cloud
  sandbox.

* 🎨 style(hetero-agent): pass spawn-args builders an options object

Positional list grew to four args with mixed types — switch to a single
`BuildSpawnArgsParams` object so call sites read by field name and adding
future per-agent flags doesn't push every other caller around.
2026-05-10 12:15:04 +08:00
Arvin Xu ca6c9ad7a2 🐛 fix(local-system): guard readFile against binary blobs and oversized output (#14602)
* 🐛 fix(local-system): guard readFile against binary blobs and oversized output

Previously `lobe-local-system.readFile` would happily decode any extension
as UTF-8 and return the entire content. Reading a 27KB base64-encoded git
bundle blew up the next LLM call to 3.28M tokens / 416s and triggered a
DB rollback. The default 200-line cap was bypassed because base64 was a
single very long line.

Add four layers of protection in `readLocalFile`:
- Hard-reject extensions outside the text-readable + special-parser
  whitelist with a structured error pointing the agent at runCommand.
- Sniff the first 8KB and refuse files that look binary (null bytes or
  >30% non-printable chars).
- 10MB hard size cap before the file is read into memory.
- Cap each returned line at 8K chars and total output at 500K chars,
  with `truncated` / `linesTruncated` flags surfaced in the result.

Refs LOBE-8703.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(file-loaders): preserve UTF-16 text files without a BOM in binary sniffer

The binary sniffer rejected UTF-16LE/BE files that lacked a BOM because
their alternating 0x00 bytes tripped the null-byte heuristic. `TextLoader`
already has a `detectUtf16NoBom` heuristic for these Windows-style exports;
extract it to a shared `detectUtf16` util and run it in the sniffer before
the null-byte check, decoding with the matching variant for the printable
ratio test instead of declaring the file binary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(local-system): render WriteFile new files as a unified diff

Switch the WriteFile render from a syntax-highlighted preview to a
synthesized "new file" unified diff via PatchDiff, matching the
EditLocalFile visual. Markdown files keep their rendered preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(local-system): exercise readFile / readFiles end-to-end

The previous LocalFileCtr.readFile / readFiles tests deep-mocked
node:fs/promises and @lobechat/file-loaders. Since the controller is a
thin pass-through to readLocalFile, the assertions ended up testing
shell internals (already covered in packages/local-file-shell), and
broke as soon as readLocalFile gained new pre-flight checks.

Move them into a sibling LocalFileCtr.readFile.test.ts that runs
against a real tmpdir + real file-loaders, so adding more upstream
guards no longer requires touching this suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:01:24 +08:00
YuTengjing ecaec1bf9d feat: add user activity business hook (#14601) 2026-05-10 11:18:39 +08:00
Hardy 23dced5de9 ♻️ refactor(siliconcloud): sync models with API, fix duplicates, adjust reasoning params (#14464)
* ♻️ refactor(siliconcloud): sync models with API, fix duplicates, adjust reasoning params

* 🐛 fix(siliconcloud): fix GLM-4.7 checkModel casing to match model ID
2026-05-10 10:40:52 +08:00
AmAzing- b5c4abcaef 🌐 i18n: update banner copy translations (#14623) 2026-05-10 10:28:50 +08:00
AmAzing- e72f30e53e 💬 i18n: remove trailing punctuation from banner titles (#14622) 2026-05-10 10:23:55 +08:00
YuTengjing 7bd7baf6b6 feat: add Gemini 3.1 Flash-Lite provider cards (#14604) 2026-05-10 10:04:27 +08:00
YuTengjing 78fc0931b0 ♻️ refactor: remove model extend param options (#14607) 2026-05-10 10:02:35 +08:00
René Wang b15c9e43d4 📝 docs: add intro and screenshot to task scheduler changelog (#14585) 2026-05-10 09:53:02 +08:00
Neko 25ee8221a7 🐛 fix(database,utils,userMemories): should perfer to use paradedb.match(...) instead of hardcoded normalizer (#14590) 2026-05-10 01:39:16 +08:00
Arvin Xu 8fa7607747 🐛 fix(database): attach error listeners to Neon/Node pools to prevent Lambda crash (#14606)
* 🐛 fix(database): attach error listeners to Neon/Node pools to prevent Lambda crash

NeonPool (and NodePool) inherit pg.Pool semantics: when a backend connection
drops on an idle client the pool emits 'error'. With no listener Node
escalates that into uncaughtException — on Vercel this killed the entire
Lambda process (exit 129) and produced a 1805-crash avalanche in 5 minutes,
spiking Neon connection count from 30 to 330+ as half-closed sockets
accumulated (LOBE-8704).

Primary fix: attach `.on('error', ...)` to both pool variants in
`packages/database/src/core/web-server.ts` so the error is logged but
swallowed; the pool recovers on its own per pg docs.

Defense in depth: register `uncaughtException` / `unhandledRejection`
handlers in `instrumentation.ts` (gated to nodejs runtime) so any future
unhandled error doesn't take down the process either.

Refs: https://node-postgres.com/apis/pool#error

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔧 chore: drop process-wide uncaughtException handler

Per review on #14606: the catch-all listener in instrumentation.ts swallowed
every uncaughtException / unhandledRejection — not just NeonPool errors —
leaving the process in an undefined state instead of letting the platform
restart it, and would mask future production bugs.

LOBE-8704 is fully addressed by the targeted pool listeners in
packages/database/src/core/web-server.ts; the broad backstop is unnecessary
and unsafe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:30:16 +08:00
sxjeru d3159436e8 💄 style: Add new DeepSeek-V4 models (#14110)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-10 01:05:24 +08:00
Arvin Xu ca3879a23c 🐛 fix: gateway client-tool pluginState + drop redundant Exit code: 0 tail (#14596)
* 🐛 fix(agent-runtime): forward pluginState through gateway client tool result

Gateway-mode client tool results lost the `state` field at three points:
the toolResult Zod schema didn't declare it (silently stripped by safeParse),
the ToolResultPayload interface didn't carry it, and projectToExecutionResult
didn't return it. As a result the "技能状态" tab was always empty for tools
dispatched via Agent Gateway, even though clients send `state` correctly and
non-gateway paths persist it as `pluginState`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(prompts): suppress redundant `Exit code: 0` tail in command result

For successful runs, "Command completed successfully." already conveys
the same signal — appending "Exit code: 0" was just noise the LLM had
to skim past. Non-zero exit codes (130 SIGINT, 137 OOM, etc.) keep the
line so the diagnostic information remains available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(prompts): treat non-zero exit code as command failure in result header

`success` is the envelope ("the service responded") and `exitCode` is the
command's own status — they're independent. With `success: true` +
`exitCode: 137` the prior format rendered "Command completed successfully."
on top of a SIGKILL/OOM, lying to the LLM.

Now the header is derived from both: any non-zero exit folds the message
into the failure branch as "Command failed with exit code N[: error]".
The trailing "Exit code: N" line is gone — the same info now lives in the
header, so success rendering is also free of the redundant zero tail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:53:31 +08:00
sxjeru 7a3de98348 🐛 fix(gemini): handle zero cachedContentTokenCount in usage conversion (#14567)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-10 00:36:26 +08:00
Arvin Xu 56ddccdc1c 💄 style(topic): add copy session ID to topic dropdown menu (#14595)
 feat(topic): add copy session ID to topic dropdown menu

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:26:39 +08:00
Arvin Xu cd2c074843 feat: home daily brief with linkable welcome + paired input hint (#14589)
*  feat: home daily brief with linkable welcome + paired input hint

Add a per-user "daily brief" surface to the home page. A cron-driven
backend (in the cloud repo) writes paired { welcome, hint } entries
into Redis under `aiGeneration:home_brief:{userId}`. This change exposes
that data through:

- `RedisKeys.aiGeneration.homeBrief` key builder
- `home.getDailyBrief` lambda router query that reads the cached payload
- `homeService.getDailyBrief` client and `useHomeDailyBrief` hook with
  shared rotating index via `useSyncExternalStore`
- `WelcomeText` runs a custom typewriter (supports real `\n` line breaks
  and parses inline `[label](url)` markdown links so cached entity
  references become clickable; falls back to the i18n welcome list)
- `InputArea` shows the matching hint as the chat input placeholder

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: extract daily-brief Redis read into HomeService

Mirrors the AgentService pattern: the lambda home router was reaching
into Redis directly, which mixed I/O concerns with the routing layer.
Move the read into a dedicated `HomeService` so future home-page reads
have a clear home and the router stays thin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix: keep WelcomeText typewriter index in sync with shared store

Before: DailyTypewriter held its own `sentenceIndex` state, separate
from the module-level `currentIndex` in `useHomeDailyBrief`. After
the home page rotated past the first pair, navigating away and back
remounted the typewriter and reset its local index to 0 — but the
external index stayed where it was. InputArea read the hint at the
stale external index while WelcomeText restarted at pair 0, breaking
the welcome / hint pairing.

Make the typewriter fully controlled: drop the local `sentenceIndex`,
expose `currentIndex` from `useHomeDailyBrief`, and pass it as a prop.
On `pause`, the typewriter just calls `onSentenceComplete` — the
parent flips the shared index, the new prop flows back, the reset
effect re-arms typing for the new sentence. Single source of truth,
remount-safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(redis): factor JSON cache reads into getJSONFromRedis util

Three call sites were inlining the same "fetch + null-check + JSON.parse
+ try/catch" recipe against a scoped Redis client:

- AgentService.getAgentWelcomeFromRedis
- HomeService.readDailyBriefFromRedis (new)

Move the recipe into a small `getJSONFromRedis<T>` helper next to the
other Redis utilities and have both services delegate to it. Caller
keeps responsibility for resolving the right scoped client (we don't
want to hide the prefix selection inside the helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): use live editor content for Enter-to-send guard

When typing into the home input and pressing Enter immediately, the
empty-message guard sometimes wrongly bailed out. The cause: the guard
read the cached `inputMessage` in `useChatStore`, which is populated by
the editor's async `onMarkdownContentChange`. Lexical commits its
update on a microtask after each keystroke, so a fast type-then-Enter
fires the send path before the cache catches up.

`SendButtonHandler` already passes `getMarkdownContent` through — read
it instead, falling back to the cached value if the handler is invoked
without it. Also propagate the live message into all `inputActiveMode`
branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(home): accept daily-brief hint as the message on empty Enter

Press Enter on the empty home input → send the currently displayed
daily-brief hint as the message (smart-compose / Tab-to-accept style).
Trims the cosmetic trailing ellipsis and rotates the carousel so the
next press picks up a different pair.

Falls through to the previous "no content, skip" path when there's
neither a typed message nor a hint to use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(home): scope daily-brief SWR key + rotation index by userId

The SWR key was a constant string, so an account switch within the same
SPA session — sign out + sign in as another user, or a multi-account
swap that keeps `isSignedIn` true — could surface the previous user's
cached pairs from the same slot. The keyspace in Redis is per-user,
so the served data leaks personalization.

Include the resolved userId in the SWR key, and reset the module-level
rotation index on user change so the new account starts from pair 0
rather than inheriting a stale offset (which could also point past the
end of a smaller pairs list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:52:13 +08:00
LiJian f35e2d843a 🐛 fix: first inject the cloudecc runtime session should use the existingStatus (#14592)
* 🐛 fix: skip reconnect when gateway action already established a connection

Race condition on new-topic first message:
1. switchTopic loads runningOperation → useGatewayReconnect fires
2. executeGatewayAgent calls connectToGateway (status: connecting)
3. reconnectToGatewayOperation overwrites with resumeOnConnect:true
4. Gateway sees resume on a brand-new session → no events → stuck

Second message works because the client store's runningOperation is
stale (from the first op), so SWR deduplications and no reconnect fires.

Fix: bail out of reconnectToGatewayOperation if gatewayConnections
already shows connecting/connected for that operationId.

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

* 🐛 fix: always pass --cwd /workspace for cloud CC to ensure session resume

CC stores session files at ~/.claude/projects/<encoded-cwd>/.
Without an explicit --cwd the actual working directory can differ
between sandbox invocations, so --resume <heteroSessionId> fails
to locate the previous session files even though the container is
persistent and the ID is correctly stored in topic.metadata.

Default cwd to /workspace for cloud runs (desktop keeps its own
explicit path), guaranteeing a stable session-file location across
page reloads within the same sandbox lifecycle.

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

* 🐛 fix: extend reconnect guard to cover all in-flight connection statuses

The previous guard only skipped reconnect for 'connecting'/'connected'
but the connection can already be in 'authenticating' or 'reconnecting'
by the time useGatewayReconnect fires, leaving the race window open.

Flip the condition: skip for any status that is not 'disconnected'.

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

* 🐛 fix: restore cold replica state in HeterogeneousPersistenceHandler

Vercel serverless functions are stateless per-request, so `operationStates`
is empty on every `heteroIngest` call. loadOrCreateState always cold-creates.

#14539 fixed `toolMsgIdByCallId` restoration but left `accumulatedContent`,
`toolState.payloads`, and `toolState.persistedIds` empty on cold load,
causing two bugs:

- Content truncation: cold instance starts with `accumulatedContent=''`,
  accumulates only the current batch's text, then writes that shorter string
  on the next step boundary or terminal — overwriting the longer content the
  previous write had already stored in DB.

- Tool duplication / tools[] overwrite: `persistedIds={}` on cold load
  means every `tools_calling` event re-creates already-persisted tool
  messages, and `payloads=[]` means phase 1/3 writes only the current
  batch's tools, wiping previous tools from `assistant.tools[]`.

Fix: in `loadOrCreateState`, fetch the current assistant message and restore
`accumulatedContent`, `accumulatedReasoning`, `toolState.payloads`, and
`toolState.persistedIds` from it. Cold load is now equivalent to warm load.

Also adds two regression tests covering the cold-replica scenarios.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:44:09 +08:00
Arvin Xu 53f6fe43b4 💄 style: use visible divider between queued messages (#14593)
💄 style(QueueTray): use visible divider color between queued messages

The previous `colorBorderSecondary` rendered the divider effectively
invisible on the elevated dark surface. Switch to `colorFillTertiary`
so stacked queued messages have a perceptible separator.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:06:24 +08:00
Rdmclin2 69b1d9503e 🐛 fix: slack connect error & slash commands (#14591)
* feat: displayToolCalls default undefined

* chore: restrict billboard to home page

* fix: add slack bot scope

* fix: show billboard in home nav
2026-05-09 21:43:13 +07:00
Neko 395eb8598c feat(agent-signal,prompts,database): self-review now proposal actions to briefs, and automatically execute actions (#14583) 2026-05-09 22:34:19 +08:00
Innei 746bf4f316 💄 style(intervention): polish confirmation bar layout (#14587) 2026-05-09 22:21:39 +08:00
AmAzing- 58dd297141 chore: Refine homepage banner copy for channels and skills (#14588) 2026-05-09 22:09:18 +08:00
AmAzing- a4e5a20b4d 🛠️ fix: unify SKILL.md frontmatter parsing and edit validation in agent documents (#14566) 2026-05-09 22:04:05 +08:00
LiJian 95f41f8cec feat: add signOperationJwt with 4h expiry for hetero-agent operations (#14586)
*  feat: add signOperationJwt with 4h expiry for hetero-agent operations

- Add `signOperationJwt(userId)` to internalJwt.ts with 4h expiry and
  `purpose: 'hetero-operation'`, so Claude Code / Codex tasks running
  beyond 5 minutes no longer hit 401 on heteroIngest / heteroFinish
- Update `execAgent` hetero path to use `signOperationJwt` instead of
  `signUserJWT`; gatewayToken continues to use 5m `signUserJWT`
- Add unit tests in `__tests__/internalJwt.test.ts` with correct mocks
  for `jose` (SignJWT class + importJWK) and `authEnv`, covering all
  three signing functions and the expiry difference assertion

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

* 🔒 security: restrict hetero-operation JWT scope to heteroIngest/heteroFinish

A leaked 4-hour sandbox LOBEHUB_JWT must not be replayable against any
other authenticated lambda route.

- Forward `purpose` claim from JWT payload through validateOIDCJWT →
  tokenData → oidcAuth context so middlewares can inspect it
- oidcAuth: reject tokens with purpose 'hetero-operation' — they cannot
  reach any normal authedProcedure route
- New heteroOperationAuth middleware: exclusively accepts
  purpose 'hetero-operation' tokens, rejects all others
- Export heteroAuthedProcedure (baseProcedure + heteroOperationAuth +
  userAuth) from trpc/lambda/index.ts
- heteroIngest / heteroFinish now use heteroAgentProcedure built on
  heteroAuthedProcedure + serverDatabase + HeterogeneousAgentService
- Tests: heteroOperationAuth (4), oidcAuth (4), update heteroIngest
  test caller to supply purpose:'hetero-operation' context (23 total)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:57:50 +08:00
lobehubbot 0516184b45 🔖 chore(release): release version v2.1.57 [skip ci] 2026-05-09 13:36:15 +00:00
lobehubbot f7fbc1c833 Merge remote-tracking branch 'origin/main' into canary 2026-05-09 13:33:21 +00:00
Innei 0f5fb54cb6 🚀 release: 20260509 (#14563)
# 🚀 LobeHub Release (20260509)

**Release Date:** May 9, 2026  
**Since v2.1.56:** 236 merged PRs · 19 contributors

> Agent Task System reaches general availability, the Agent Signal
pipeline runs nightly self-review with skill-aware policies, the
heterogeneous-agent runtime crosses replica boundaries, inline documents
become a first-class context source, and bot platforms expand across
Messager, Line, and Telegram.

---

##  Highlights

- **Agent Task System (GA)** — End-to-end task execution platform:
templates, tracking, comment tools, parent reassignment, scheduled cron,
and dependency-ordered batch runs. (#14540, #14515, #14517, #14272,
#14246, #14418, #14403, #14488)
- **Agent Signal nightly self-review** — Wired self-review loop with
prompt + DB support, exponential-backoff retry on receipt listing,
skill-aware policy, and improved skill-intent detection. (#14543,
#14542, #14281, #14409, #14526, #14437)
- **Inline documents in KB tool** — BM25 search and `docs_*` read for
inline document grounding; agent documents usable as VFS. (#14494,
#14222)
- **Inline agent cards in chat** — `lobeAgents` markdown tag renders
agent profile cards inline; clickable card after `createAgent`. (#14495,
#14493)
- **Heterogeneous agent runtime** — Cloud hetero exec pipeline steps 3+4
land, persistence recovers across Vercel replicas, server-side
ingest/finish handler, and `lh hetero exec` CLI. (#14486, #14539,
#14444, #14431)
- **Bot platforms expand** — Messager, Line, DM pair policy, and
messenger DB tables; Telegram API path restored. (#14442, #14207,
#14211, #14496, #14519)
- **Visual analysis tool** — New visual understanding tool, with trigger
tracking and flattened schema. (#14378, #14399, #14550)
- **DeepSeek V4 Pro as OSS default** — OSS deployments ship with
DeepSeek V4 Pro by default; DeepSeek Anthropic runtime supported.
(#14555, #14312)

---

## 🏗️ Core Agent & Architecture

### Agent Task System

- **Task System GA** — End-to-end execution platform now available.
(#14540)
- **Templates, comments, reparenting** — Template tracking, comment
tools, and parent reassignment. (#14515, #14517, #14488)
- **Cron + dependency-ordered runs** — Scheduled status with cron editor
and dependency-ordered subtask batches. (#14246, #14418, #14272)
- **Inspector + chip UI + batch tasks** — Task Inspector/Render
registry, batch `createTasks`/`runTasks`, and chip-based agent-documents
inspector. (#14403, #14404)
- **Recommend templates regardless of brief count** — Recommendations no
longer suppressed when briefs are sparse. (#14508)
- **Scheduling resilience** — Manual run no longer eats next scheduled
tick; recurring tasks survive brief resolution. (#14304, #14348)
- **Brief synthesis** — Auto-synthesize topic briefs; brief actions
revamp; mute resolved-brief icon on home. (#14324, #14228, #14452)
- **Task list & detail polish** — Topic operation ID exposed; task
drawer Gateway reconnect. (#14282)

### Agent Signal pipeline

- **Nightly self-review wired** — Prompt + DB support for the
self-review loop. (#14543)
- **Self-review activities push to briefs** — Activities during nightly
self-reflection now create briefs. (#14437)
- **Skill management policy** — New policy for Skill management running
inside Agent Signal. (#14281)
- **Skill intent detection & routing** — Improved detection plus direct
intent handling when `hintIsSkill`. (#14409, #14526)
- **Document tool outcome rendering** — Decision view restores missing
document tool outcomes. (#14534)
- **Exponential backoff retry** — Listing signal receipts retries with
jittered backoff. (#14542)
- **Easier-to-use signals** — Structural simplification +
recent-activities surface for receipts. (#14290, #14326, #14407)

### Heterogeneous agent runtime

- **Cloud hetero exec pipeline (steps 3 + 4)** — Refactor lands the next
two stages of the cloud hetero agent execution pipeline. (#14486)
- **Persistence recovery on Vercel** — Hetero state recovered across
replica boundaries. (#14539)
- **Server-side ingest/finish + persistence** — `aiAgent.heteroIngest` /
`heteroFinish` handlers. (#14444)
- **`lh hetero exec` CLI** — Standalone heterogeneous agent runs from
CLI. (#14431)
- **Gateway round-trip loading** — `execAgentTask` keeps the input box
in loading state through the full round-trip. (#14503)
- **Provider SDK type routing** — Provider routing now respects SDK
type. (#14520)
- **DeepSeek reasoning preserved** — `reasoning_content` preserved in
OpenAI-compatible runtime for DeepSeek models. (#14546)

### Knowledge & inline docs

- **KB tool BM25 + docs read** — BM25 search and `docs_*` read
integrated for inline documents. (#14494)
- **Agent documents as VFS** — FS-compatible output for agent documents.
(#14222)
- **`lobeAgents` markdown tag** — Inline agent cards rendered from a
markdown tag. (#14495)
- **Clickable agent card after `createAgent`** — Mentions and
recommendations become clickable. (#14493)
- **ExplorerTree** — Generic tree component built on `@pierre/trees` for
reusable explorer surfaces. (#14094)
- **Local file mention snapshots** — Mentions can now snapshot local
files. (#14278)

### Architecture

- **Agent Hono routes** — New agent routes added on Hono. (#14535)
- **`/api/agent` migrated to Hono** — Remaining `/api/agent` routes
finish their migration. (#14478)
- **Agent marketplace merged into web-onboarding** — Reduces package
fragmentation. (#14514)
- **Producer pipeline extracted** — Shared package for the producer
pipeline. (#14425)
- **`agentDispatcher.selectRuntimeType`** — New runtime selection
abstraction. (#14428)
- **pnpm v11 migration** — Workspace consolidated. (#14316)
- **Browser-compatible frontmatter parser** — Replaces `gray-matter`.
(#14435)

---

## 📱 Platforms & Integrations

- **Messager support** — New messager package wired into the chat
surface. (#14442)
- **Messenger DB tables** — IM bot integration gains its persistence
layer. (#14496)
- **Line bot** — Initial Line support and downstream optimization.
(#14207, #14448)
- **DM pair policy** — Group/DM pair-based delivery. (#14211)
- **Telegram API restored** — Missing Telegram API path reconnected.
(#14519)
- **xAI Responses tools stabilized** — Plus unsupported parameter
handling. (#14462, #14445)
- **Volcengine websearch via ResponseAPI** — Built-in websearch for
Volcengine. (#14216)

---

## 🤖 Models & Providers

- **DeepSeek V4 Pro default for OSS** — OSS distribution defaults to
DeepSeek V4 Pro. (#14555)
- **DeepSeek Anthropic runtime** — Anthropic-shape runtime support for
DeepSeek. (#14312)
- **GPT-5.5 / GPT-5.5 Pro** — New OpenAI tier. (#14142)
- **Grok 4.20 / Grok 4.3 / LobeHub-hosted Grok 4.3** — (#14253, #14382,
#14446)
- **Gemma 4 + provider settings normalization** — (#13313)
- **gpt-image-2 + step-image-edit-2** — (#14253, #14329)
- **Model bank refresh + original-pricing display** — Batch model
updates and pricing surfaces. (#14070, #14391)
- **Hunyuan migrated to TokenHub for Hy3 Preview** — (#14108)
- **Reject lobehub model ids no longer in the bank** — (#14261)
- **Hide runtime-only aliases** — Runtime-only model aliases no longer
leak into the model picker. (#14552)

---

## 🖥️ User Experience

### Onboarding

- **Shared prefix steps** — Language and privacy extracted as shared
prefix steps. (#14538)
- **Identity intervention card simplified** — Plus tool result renders
cleanup. (#14505, #14506)
- **Welcome polish + web-onboarding tool UI** — (#14475)
- **Templates fetched from market API** — (#14286)
- **Virtual model id for default onboarding model** — (#14311)
- **Skip / mode-switch footer behind feature flag** — Footer guarded for
desktop and web initialization. (#14560)

### Home & navigation

- **Home recents performance** — Recents refresh periodically and inline
task status; brief and task-template fetch overhead trimmed. (#14518,
#14516)
- **Home refactor + skill-connect recommendations** — Restructured home
with skill-connect recommendation system. (#14266, #14214)
- **Tasks in agent sidebar** — Tasks moved from welcome card into the
sidebar list. (#14500)
- **Sidebar collapse persists** — Home sidebar collapse state stored.
(#14473)
- **Agent-specific topic grouping** — Plus improved empty state and
agent identity in topic search. (#14225)
- **MentionMenu scroll fix** — Mention menu no longer clips inside chat
input. (#14533)

### Conversation & chat

- **Follow-up chips fill input** — Clicking a follow-up chip now fills
the input instead of sending immediately. (#14536)
- **Quick-reply chips below assistant messages** — (#14350)
- **Inline single-tool assistant group + leading sentence promotion** —
(#14244)
- **Assistant-group rendering** — Per-segment content overrides flow
into MessageContent. (#14504)
- **Tool call timer fix** — Timer no longer resets when tool calls
collapse or expand. (#14513)
- **Streaming re-render reduction** — Reference stabilization and
self-subscribing components. (#14470)
- **Topic chat drawer feedback input** — (#14392)

### Skills, agents, devtools

- **Managed skill folders** — Agent view displays managed skill folders
and aligns delete confirmations. (#14553)
- **Review tab + bulk git diffs** — New Review tab with bulk diffs;
gating uses effective working directory. (#14334, #14512)
- **Devtools gallery rebuild** — Plus Review polish, queue-tray images.
(#14423)
- **Agent mock devtools** — Playback & fixture viewer. (#14436)

### Desktop & CLI

- **App tray visibility setting** — (#14463)
- **Notification settings in desktop** — (#14491)
- **Multimodal input across CLI / shared spawn / desktop** — (#14433)
- **CLI bot + userId guide** — (#14258)

---

## 🔧 Tooling

- **Visual analysis tool** — New visual understanding tool with
flattened schema. (#14378, #14550)
- **GitHub marketplace tool UI** — (#14420)
- **Drop "Local" prefix and `____builtin` suffix from tool names** —
(#14364, #14289)
- **Sanitize provider tool names** — Avoids invalid characters from
external providers. (#14510)
- **Generation moderation context** — Moderation context passed through
the generation pipeline. (#14541)
- **Visual analysis trigger tracking** — (#14399)
- **Claude thinking signature sanitization** — History signatures
sanitized when replaying Claude conversations. (#14499)
- **Responses input media sanitization** — Assistant media sanitized in
Responses input. (#14497)

---

## 🔒 Security & Reliability

- **Security:** Removed the `/webapi/proxy` route and dead URL-manifest
plugin code to shrink the SSRF surface. (#14549)
- **Security:** Sessions revoked after password reset. (#14424)
- **Reliability:** Added `prompt_cache_key` to OpenAI chat requests for
stable cache hits. (#14349)
- **Reliability:** `onFinish` now fires even when the browser tab is
backgrounded mid-SSE stream. (#14461)
- **Reliability:** Better-auth session refetch preserves user fields
rather than overwriting them. (#14531)
- **Reliability:** User-memory queries sanitize backticks; user-memory
errors now explicitly injected so failures stay visible. (#14524,
#14525)
- **Reliability:** Auth captcha retries handled; input loading unsticks
on `auth_failed` and recoverable `auth_expired`. (#14346, #14419)
- **Reliability:** Trace snapshot finalized on error path. (#14440)
- **Reliability:** Drop `switchTopic` race under rapid sidebar clicks.
(#14115)
- **Reliability:** PDF chunking logic fixed to prevent vectorization
failure. (#14327)
- **Performance:** Marketplace fork uses a batched API for parallel
installs. (#14537)
- **Performance:** Review tab open latency cut ~9× on large dirty trees.
(#14338)

---

## 👥 Contributors

Huge thanks to **18 contributors** who shipped **236 merged PRs** this
cycle.

@hezhijie0327 · @sxjeru · @yueyinqiu · @octo-patch · @hardy-one ·
@Coooolfan · @CanYuanA · @BillionClaw · @arvinxx · @tjx666 · @Innei ·
@Neko · @AmAzing129 · @Rdmclin2 · @LiJian · @sudongyuer · @rivertwilight
· @cy948

Plus @lobehubbot for i18n and translation maintenance.

---

**Full Changelog**:
https://github.com/lobehub/lobe-chat/compare/v2.1.56...release/weekly-20260509
2026-05-09 21:30:37 +08:00
Innei feaaaba2a9 💄 style(settings): remove image avatar from lab input markdown rendering item (#14582) 2026-05-09 21:15:02 +08:00
YuTengjing 21f6f94bed 🐛 fix: polish task agent manager (#14569) 2026-05-09 20:58:29 +08:00
AmAzing- b180c03e04 feat: migrate Notion to LobeHub Market (#14578)
Migrate Notion to LobeHub Market
2026-05-09 20:55:26 +08:00
Arvin Xu 0d39dff2d5 🐛 fix(agent-runtime): recover malformed tool_call names instead of finishing silently (#14577)
* 🐛 fix(agent-runtime): recover malformed tool_call names instead of finishing silently

When an LLM emits tool_call names without the `____` separator (e.g. `activateTools`
instead of `lobe-activator____activateTools`), the resolver dropped them silently and
the harness finished with "completed without tool calls" — empty assistant bubble,
no error in dashboards.

Three layers of defense:

- Resolver fallback: when the bare name uniquely matches an API across known
  manifests, recover the identifier; ambiguous matches still drop to avoid
  false binding.
- StreamingHandler logs unresolved tool_call names so the silent-drop path is
  observable in debug output.
- GeneralChatAgent surfaces the unresolvable count and names in reasonDetail
  so dashboards can distinguish this from a genuine no-tool completion.

Fixes LOBE-8696

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-runtime): restrict bare-name fallback to tools offered this turn

Address review feedback on the LOBE-8696 resolver fallback. The
manifests map passed to ToolNameResolver.resolve is broader than the
tools actually sent to the LLM (the client builds it from every
installed plugin and every builtin; the server can preserve manifests
even after a step deactivates a tool). Without a turn-scope
restriction:

- A model returning a malformed bare name could resolve to a tool that
  was not enabled for this turn.
- A disabled duplicate API name could shadow the enabled call and make
  it look ambiguous, dropping a valid call.

Pipe an `offeredToolNames` list (the names actually sent in this LLM
payload) into resolve(): when set, the missing-prefix fallback only
considers manifests whose generated tool name appears in the list.

- ToolNameResolver.resolve gains an optional `offeredToolNames` param.
- internal_transformToolCalls forwards the list through.
- createAgentExecutors builds resolvedAgentConfig before the
  StreamingHandler so the closure can bind the offered names — same
  list that gets sent to the model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:47:21 +08:00
LiJian 6fb24adbd2 feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context (#14568)
*  feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context

- Add CloudRepoSwitcher component (web-only multi-select repo picker)
  - Pre-topic selections buffered in module singleton (pendingTopicRepos)
  - Consumed by gateway.ts at topic creation time via appContext.initialTopicMetadata
  - Eliminates race condition where updateTopicMetadata dropped silently
- Extend ChatTopicMetadata with repos[] field for multi-repo binding
- Add initialTopicMetadata to ExecAgentAppContext so repos are written to
  topic metadata at creation time (server-side, zero race condition)
- Extend ExecAgentSchema Zod schema with initialTopicMetadata
- Inject GITHUB_TOKEN env var into sandbox so CC can use git/gh CLI
- Build cloudHeteroContext with GitHub auth section when token is available
- Add workingDirectory selector for web (repos[0] fallback)
- Add refreshTopic call in gateway path after new topic creation
- Add CloudHeterogeneousConfig profile editor for GITHUB_REPOS / GITHUB_CRED_KEY
- Extend sandboxRunner with repo clone setup script and systemContext support

* 🐛 fix: add open-source stub for pendingTopicRepos to fix Vite build

* ♻️ refactor: move pendingTopicRepos real impl into submodule, remove cloud override

* 🐛 fix: consume pendingTopicRepos only after topic creation succeeds

* 🐛 fix: add missing getPendingTopicRepos import in gateway

* 🔒 fix: address security and dead-code issues from PR review

- sandboxRunner: sanitize repo dir name to prevent shell injection
- sandboxRunner: use git insteadOf (-c flag) so token is never stored in .git/config
- cloudHeteroContext: fix return type from string|undefined to string (dead branch)
- CloudRepoSwitcher: remove unreachable empty-list branch in popover content

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

* 💬 i18n: add claude setup-token hint to token description

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

* 🐛 fix: remove incorrect web hetero→gateway forced routing in agentDispatcher

On web, heterogeneousProvider is ignored — routing falls through to isGatewayMode.
Cloud CC only runs when gateway mode is enabled; gateway.ts handles sandbox
spawning when it detects a hetero provider.

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

* 🐛 fix: restore web hetero→gateway routing; update stale test

On web, a configured heterogeneousProvider always routes to gateway —
the cloud sandbox is the only execution environment regardless of
isGatewayMode. The test assumed the pre-cloud-CC world where web
ignored hetero providers entirely.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:39:14 +08:00
Innei a09991af8c 📝 docs(version-release): enforce git-derived PR refs and metrics (#14575)
* 📝 docs(version-release): enforce git-derived PR refs and metrics

Add the skill's first-class hard rules for computing release-note inputs
from git instead of memory: latest-tag base via `git describe`, PR refs
from commit subjects, metric counts from `wc -l`, handle resolution via
`gh pr view`, and a pre-publish `comm -23` diff that must be empty.
Also adds @cy948 to the team roster and notes Tsuki / René Wang's
commit-author aliases so contributor classification stops drifting.

* ♻️ refactor(version-release): split skill into router + per-flow references

SKILL.md was 426 lines covering three distinct flows. Split it so each
flow lives next to its own checklist:

- reference/minor-release.md — minor workflow (lifted from SKILL.md)
- reference/patch-release-scenarios.md — patch flows (existing)
- reference/release-notes-style.md — long-form changelog standard,
  template, and Computing Inputs hard rules (lifted from SKILL.md)

SKILL.md now reads as a router (~100 lines) with shared CI trigger
rules, post-release automation, precheck, and hard rules. Cross-links
between references replace the previous in-file jumps. Also fixes a
prettier-mangled redirect (`< some-pr-by-them >`) by using a `$PR`
variable instead of an angle-bracket placeholder.

* 📝 docs(version-release): add Hotfix and DB Migration variants to release-notes-style

The Canonical Structure was implicitly long-form (Minor / Weekly), and
hotfix authors had to read `changelog-example/hotfix.md` to learn it
existed. Make the divergence explicit:

- New § Variants for Shorter Releases describes Hotfix structure
  (Scope / What's Fixed / Upgrade / Owner) and DB Migration structure
  (Migration overview / Operator impact / Rollback) as overrides of the
  canonical long-form layout.
- Renamed the canonical section to "Canonical Structure (Long-Form:
  Minor / Weekly)" so the boundary is visible.
- Added Hotfix entry to Release Size Heuristics.
- Added a Hotfix subsection to Quick Checklist so the verification
  gates differ from long-form (no metric line / no Contributors / Owner
  resolved via gh).
2026-05-09 20:32:44 +08:00
YuTengjing 4c76d2430f 🐛 fix: remove signin captcha flow (#14573) 2026-05-09 19:49:04 +08:00
Innei 8ed31dfca4 🐛 fix(docker): replace pnpm init with static package.json in /deps (#14576)
`pnpm init` writes `devEngines.packageManager: { version: "^11.0.9" }`
into the generated package.json. corepack@latest rejects ranges in this
field with "Invalid package manager specification ... expected a semver
version", causing the subsequent `pnpm add pg drizzle-orm` to exit 1.

Skip init and write a minimal package.json directly so corepack has
nothing to validate.
2026-05-09 19:36:09 +08:00
YuTengjing c374892fea 🐛 fix: add temporary email auth error locale (#14564) 2026-05-09 18:50:32 +08:00
Rdmclin2 4617468e87 🐛 fix: add bot callback service (#14570)
fix: add bot callback service
2026-05-09 17:45:34 +07:00
LiJian 4c3a71a2c3 🐛 fix: sanitize sensitive comments and examples from production JS bundle (#14557)
* 🐛 fix: sanitize sensitive comments and examples from production JS bundle

- Replace app.example.com with RFC 2606 example.com in agent-browser skill content
- Replace password-stdin examples with interactive auth prompts
- Remove hardcoded password-like strings from code examples
- Reword flagged code comments in page-agent system role

Addresses TAC Security CASA Tier 2 DAST Info findings:
Information Disclosure - Suspicious Comments (CWE-615)

The flagged strings appeared in SPA production bundles:
- /_spa/assets/chat-*.js
- /_spa/assets/index-*.js

* 🐛 fix: revert --interactive to --password-stdin in auth vault examples

The --interactive flag does not exist in agent-browser CLI (only --password
and --password-stdin are supported). Using --interactive would cause auth
save to fail and block login workflows.

Reverted both auth vault examples to use echo | --password-stdin pattern,
which pipes the password via stdin — the recommended secure approach.
2026-05-09 18:19:31 +08:00
Arvin Xu 7892e553ea 💄 style(task): activity card stop run + register /tasks in SPA proxy (#14559)
*  feat(task): add stop run action to activity card menu

Surface the existing cancelTopic flow in the task detail activity card so
users can interrupt a running topic without opening the chat drawer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task): confirm before stopping a running topic

Wrap the new Stop run action in a confirmModal so an accidental click can't
silently abort an in-flight run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(spa): register /tasks and /task in SPA proxy matcher

Without these matcher entries, the Next.js middleware never rewrote /tasks
and /task/:taskId to the SPA catch-all, so the activity feed entries 404'd
in production builds even though the routes were wired in the SPA router.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:13:24 +08:00
YuTengjing 793a8deb43 💄 style: update auth captcha retry copy (#14561) 2026-05-09 17:35:03 +08:00
Rdmclin2 e56ccf6a5c 🐛 fix: multiple account link (#14562)
* feat: avoid rebind link same account

* chore: update i18n locales

* feat: avoid discord account misslink

* feat: support slack account mis match

* fix: avoid claim conflict
2026-05-09 16:31:21 +07:00
Innei 9756daba2d 🐛 fix(onboarding): guard skip/mode-switch footer with feature flag, desktop & init checks (#14560)
- Only show the skip-and-switch footer when all conditions are met:
  AGENT_ONBOARDING_ENABLED, not desktop, server config initialized,
  and runtime enableAgentOnboarding flag is on
- Fix typo: swichMode → switchMode
- Expand tests with hoisted mocks covering each visibility condition
2026-05-09 17:09:12 +08:00
AmAzing- 2b165ec722 🎨 Refine Agent Signal receipt cards (#14558)
*  Refine agent document skill trees and delete confirms

* 🐛 fix: improve receipt card accessibility
2026-05-09 16:41:57 +08:00
YuTengjing 8105fc0b16 feat: set OSS default model to DeepSeek V4 Pro (#14555) 2026-05-09 16:36:02 +08:00
YuTengjing 2d3332200a 🐛 fix: hide runtime-only model aliases (#14552) 2026-05-09 15:53:15 +08:00
Arvin Xu cb8645f65a 🐛 fix(security): remove /webapi/proxy and dead URL-manifest plugin code (#14549)
* 🐛 fix(security): remove /webapi/proxy and dead URL-manifest plugin code

Closes #14530. The /webapi/proxy endpoint was an unauthenticated open
HTTP proxy. All client callers were dead except NewAPI provider's
browser-side pricing fetch, which now silently falls back to no-pricing
since `parsePricingResponse` already handles non-OK responses.

Removes:
- /webapi/proxy route + API_ENDPOINTS.proxy
- toolService.getToolManifest (+ packages/utils/src/toolManifest.ts)
- src/features/PluginDevModal/UrlManifestForm.tsx
- uploadService.getImageFileByUrlWithCORS
- non-MCP branch in customPlugin reinstall (silently returns for
  legacy URL-manifest plugin data)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔥 chore(model-runtime): drop /webapi/proxy hop in NewAPI pricing fetch

The browser branch routed pricing requests through /webapi/proxy to bypass
CORS. Now that the proxy is removed, fetch the upstream pricing endpoint
directly — if CORS or any other error blocks it, fall through to the
existing null fallback (NewAPI just renders without enriched pricing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(model-runtime): drop console.debug assertion in NewAPI pricing fetch

The pricing-network-error case used to assert that console.debug was
called; with the log removed, just assert the graceful fallback (no
pricing on the resulting model). Also tightens an adjacent
branch-coverage test that ESLint flagged for a useless assignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:50:31 +08:00
YuTengjing cef69e9b72 🐛 fix: flatten visual analysis tool schema (#14550) 2026-05-09 14:42:53 +08:00
LiJian d0b938a0cb 🐛 fix: recover hetero persistence state across Vercel replicas (#14539)
* 🐛 fix: recover hetero persistence state across Vercel replicas

Three-part fix for multi-replica split-brain on Vercel serverless:

1. Flush accumulated content to DB after every ingest batch so a
   replica switch mid-accumulation doesn't lose text chunks.
2. Persist `heteroCurrentMsgId` to topic.metadata on every step
   boundary so new replicas restore the correct currentAssistantMessageId.
3. Restore toolMsgIdByCallId from DB on state creation so tool_results
   landing on a different replica than their tool_use are still matched.

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

* fix: add the test fixed

* fix: slove the some topic problem

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:36:48 +08:00
AmAzing- af319af936 🐛 fix(agent): display managed skill folders and align delete confirms (#14553)
* 🐛 fix: display managed skill folders and align delete confirms

* 🐛 fix: allow recovery for orphan managed skill bundles

*  test: cover agent document group recovery paths

* 🐛 fix: render empty state for hidden skill indexes

*  test: relax agent signal hydration timeout
2026-05-09 14:32:46 +08:00
Innei 4ebd8f7f7c ♻️ refactor(onboarding): extract language and privacy as shared prefix steps (#14538)
* ♻️ refactor(onboarding): extract language and privacy as shared prefix steps

Move the language-selection and privacy/telemetry consent out of the classic
flow into a shared prefix that runs at /onboarding before branching into either
the agent or classic experience. Welcome decoration is merged with language
selection on a single screen, dropping the total step count by one.

Shared-prefix completion is derived from raw stored settings
(s.settings.general.responseLanguage and telemetry), so no new schema fields
are introduced and existing consumers that rely on the merged-default
telemetry value are unaffected.

Branch routing remains automatic (feature flag + isDesktop check) and is now
encapsulated in deriveOnboardingBranchPath. Both branch routes guard against
entering before the shared prefix is complete.

MAX_ONBOARDING_STEPS drops from 5 to 3 (FullName, Interests, ProSettings).

* ♻️ refactor(onboarding): use original Telemetry + ResponseLanguage as shared steps

Revert the merged welcome+language design. The shared prefix now reuses the
original two classic steps as-is:
- Step 1: TelemetryStep (welcome decoration + privacy/telemetry consent)
- Step 2: ResponseLanguageStep (language selection)

Also suppress the mode-switch + skip footer on the bare /onboarding path so
it only appears once the user has entered the agent or classic branch.

* 🐛 fix(onboarding): persist shared-prefix step in URL to survive locale-triggered remounts

Use react-router's useSearchParams to keep the active shared step in the URL
(?step=2). Local useState was lost when switching language for the first time
because i18next's first-time resource load triggers a remount up the tree;
the URL param survives any remount.

* 🐛 fix(onboarding): unblock branch redirect when user accepts default telemetry

Derive commonStepsCompleted from responseLanguage alone. setSettings strips
fields whose value matches DEFAULT_COMMON_SETTINGS, so accepting the default
telemetry: true left s.settings.general.telemetry undefined and the derive
selector never flipped to true — the redirect to the branch never fired.

Step 2 (language) implies step 1 was completed because the flow is sequential,
so checking responseLanguage alone is sufficient and robust against the
default-strip behavior.

* 🐛 fix(onboarding): redirect after step 2 by deriving completion from responseLanguage only

setSettings strips fields that match defaultSettings, so writing
telemetry=true (the default) never persists to s.settings.general.
That made commonStepsCompleted permanently false even after the user
finished both steps, blocking the redirect to the branch flow.

Drop telemetry from the derive check. Step 1 completion is already
tracked via the URL ?step=2 marker; step 2 completion is the only
event that needs to flip commonStepsCompleted, signalled by writing
responseLanguage (which always differs from the default since
DEFAULT_COMMON_SETTINGS has no responseLanguage entry).

* 🔨 chore(scripts): add reset-onboarding script for redoing the flow

Takes an email, clears users.onboarding, agent_onboarding, full_name,
interests and removes responseLanguage + telemetry from
user_settings.general so the user re-enters the shared-prefix
onboarding from step 1.

Usage:
  pnpm workflow:reset-onboarding <email>
  bunx tsx scripts/resetOnboarding/index.ts <email>

* 🐛 fix(signup): add refs for email and password inputs to improve focus handling

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(onboarding): skip responseLanguage auto-fill while onboarding is in progress

useInitUserState's onSuccess callback auto-fills general.responseLanguage
from navigator.language whenever the field is missing. For new users
this fired immediately after signup, which made commonStepsCompleted
(which derives from responseLanguage being set) flip to true on first
load, and CommonOnboardingPage's early-redirect skipped past the shared
prefix straight into /onboarding/agent.

Gate the auto-fill on onboarding.finishedAt or agentOnboarding.finishedAt
being set, so legacy users who finished onboarding without
responseLanguage still get the safety-net detection, but in-progress
users keep the field undefined until they explicitly choose it on the
language step.

* 🐛 fix(onboarding): refresh welcome message locale until conversation starts

ensureWelcomeMessage previously only created the welcome on first call
and skipped on subsequent ones, leaving stale welcomes locked to the
locale that was active when the topic was first created. After the
shared-prefix refactor users pick their language earlier than they
used to, so the welcome that was generated during the auto-detect
phase never gets re-translated.

Now the welcome content is rewritten in-place to match the current
responseLanguage as long as no user reply has been recorded yet
(message count <= 1). Once the conversation has started, the welcome
is left as part of the chat history.

* 🐛 fix(onboarding): update welcome message handling to render client-side and avoid persisting during onboarding

Signed-off-by: Innei <tukon479@gmail.com>

* Refactor onboarding user profile handling: remove responseLanguage field

- Removed responseLanguage from SaveUserQuestionInput and related schemas.
- Updated onboarding logic to no longer save or request responseLanguage.
- Adjusted related components and services to reflect the removal of responseLanguage.
- Enhanced user info handling to include displayName and fullName from OAuth.
- Updated tests to align with the new onboarding structure.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(onboarding): update locale handling to use i18n's resolved language

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(onboarding): remap legacy 5-step classic currentStep on shared-prefix mount

Mid-flow legacy users with persisted currentStep authored under the old
5-step classic flow (Telemetry, FullName, Interests, Language, ProSettings)
would silently skip required profile steps after the renumbering: old
step 2 (FullName) rendered Interests, old step 3 (Interests) rendered
ProSettings. Apply a one-time remap (2->1, 3->2, >=4->MAX) when Common
mounts, gated by isUserStateInit and onboarding.finishedAt absence so it
fires only for in-flight legacy users. Idempotent for new-schema values.

* refactor(onboarding): implement AGENT_ONBOARDING_ENABLED master switch for onboarding flow

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(onboarding): standardize AGENT_ONBOARDING_ENABLED naming in tests

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-09 14:31:50 +08:00
Arvin Xu de698eef92 feat: Agent Task System available (#14540)
* 🔥 chore: remove agent_task feature flag and graduate task feature

Drop the agent_task / enableAgentTask gate that was guarding the agent
task rollout. The feature is now permanently enabled, so all flag
checks, disabled-state redirects, and disabled-only fallback UI
(SuggestQuestions, CommunityAgents) are removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(brief): create regular task instead of cron job from template card

The "Add task" button on DailyBrief recommendation cards was creating an
agentCronJob (scheduled recurring job). Switch to taskService.create via
the createTask store action so it creates a one-off inbox task and
refreshes the task list, matching user expectation that the click adds
a task rather than a schedule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task): support schedule fields on task.create

The brief recommendation card needs to create a recurring scheduled
task in one shot (template carries `cronPattern`). Extend `task.create`
to accept `automationMode`, `schedulePattern`, `scheduleTimezone`, and
thread them through the service + store action. The model already
accepts these via NewTask, and the central schedule-dispatch sweep
picks the task up once status is dispatchable.

TaskTemplateCard now creates a schedule-mode task with the template's
cron pattern and the user's local timezone, restoring the recurring
behavior previously provided by AgentCronJob.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 i18n(home): shorten brief.title from "Daily brief" to "Brief"

Daily-frequency tasks are no longer the only source feeding the section
(scheduled, manual, and on-demand briefs all flow through it now), so
the more general label fits better.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task-list): show skeleton instead of blank while task list loads

Both the list view (TaskList) and kanban view (KanbanBoard / KanbanColumn)
returned null until isInit, leaving the page empty during the first SWR
fetch. Render a TaskItemSkeleton (default + compact variants) to keep the
layout stable and signal that data is loading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(git-status): toggle review panel on diff-stat click

Clicking the diff-stat chip used to always open the review panel — if
the panel was already showing review, the click was a no-op. Switch to
a toggle: clicking again with the review tab active closes the panel,
matching the implicit expectation that the chip is the entry/exit
control for that view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(brief): update TaskTemplateCard test for createTask flow

Card now calls useTaskStore.createTask with schedule fields instead of
agentCronJobService.create. Replace the agentCronJob service mock with
a useTaskStore mock exposing createTask, and assert the schedule-mode
payload (automationMode + schedulePattern + scheduleTimezone) on the
success path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief): jump to task detail after creating from template

The success toast asked users to look in the inbox agent for the new
scheduled task; navigating directly to the task detail is a clearer
landing for what they just confirmed. Drop the toast and route to
`/task/<identifier>` once createTask resolves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:07:15 +08:00
YuTengjing c03e79c118 🐛 fix: pass generation moderation context (#14541) 2026-05-09 11:27:11 +08:00
Arvin Xu aef7158f4a 🐛 fix(model-runtime): preserve reasoning_content for deepseek models in OpenAI-compat layer (#14546)
DeepSeek thinking-mode (deepseek-reasoner / deepseek-v4-*) rejects follow-up
turns when assistant history messages omit reasoning_content. Until now this
was only enforced in the dedicated DeepSeek runtime's handlePayload; users
routing deepseek model ids through any other OpenAI-compatible runtime hit a
400 with "The reasoning_content in the thinking mode must be passed back to
the API."

Move the safety net into convertOpenAIMessages so any OpenAI-compatible call
with a deepseek-named model derives reasoning_content from reasoning.content
and forces an empty placeholder for thinking-eligible models.

Fixes LOBE-8290

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:53:18 +08:00
Neko be42e056e6 feat(agent-signal,prompts,database): nightly self-review wired, improved (#14543) 2026-05-09 07:16:54 +08:00
Neko b47e32436e ️ perf(agent-signal,app): exp backoff retry of listing signal receipts (#14542) 2026-05-09 04:25:17 +08:00
Neko 85b412270b 🐛 fix(agent-signal,server): missing document tool outcome rendering into decision agent (#14534)
Emit agent document tool outcome events from client-triggered agent document tools with tool attribution so hinted skill documents can be observed by Agent Signal.

Hydrate client runtime completion back to the completed assistant message for pre-created assistant turns, allowing same-turn hinted document receipts to match the originating user message.

Harden agent document snapshot reads by falling back to markdown content when stale editor data cannot be projected for decision evidence.
2026-05-09 04:08:06 +08:00
Arvin Xu 0e216dec8e 💄 style: fill input on follow-up chip click instead of sending (#14536)
* 💄 style: fill input on follow-up chip click instead of sending

Mirrors the NameSuggestions pattern so users can edit a suggested
follow-up before sending, matching onboarding interaction conventions.

*  test: update FollowUpChips click test for input-fill behavior

Mock updateInputMessage + editor (setDocument/focus) instead of
sendMessage and assert the new fill-input flow.

* 💄 style: move branching action into the message "..." menu

Surface "branching" inside the dropdown menu (right after copy) for
assistant, assistantGroup, and user messages, instead of as an inline
toolbar icon gated behind dev mode. Drops the dev-mode bar override and
renames the now-only ACP-related selector binding to isHeteroAgent.
2026-05-09 01:33:52 +08:00
sxjeru 1d2db96a38 🐛 fix: add prompt_cache_key for OpenAI chat requests (#14349) 2026-05-09 01:15:34 +08:00
Innei 4dade3196f ️ perf(market): batch fork API for parallel marketplace install (#14537)
Rewrite the onboarding marketplace install pipeline from a serial per-agent
loop to a parallel pipeline anchored on a batched fork call. Multi-select
in the picker now finishes in roughly four parallel rounds instead of
~5N sequential round-trips.

- forkAgent tRPC now takes { items: AgentForkBatchInput[] } and returns
  per-item AgentForkBatchResult (discriminated union, best-effort: a single
  failure does not abort the batch). The upstream market endpoint stays
  per-id, fanned out via Promise.all on the server.
- installMarketplaceAgents fans out dedupe, detail fetch, and createAgent
  steps via Promise.all/allSettled and consolidates into one batched fork.
- ForkAndChat (community single-fork action) wraps its call as a 1-item
  batch and unwraps the per-item result.
2026-05-09 01:02:49 +08:00
LiJian f934e2ff46 ♻️ refactor: implement cloud hetero agent exec pipeline (step 3 + step 4) (#14486)
* refactor: add the cloud hetero execAgent Runtime way

*  feat: support session resume for heterogeneous agents (Claude Code / Codex)

- Expose `sessionId` getter on `SpawnAgentHandle` (read from `AgentStreamPipeline`)
- Pass `sessionId` to `IngestSink.finish()` so CLI reports it via `heteroFinish`
- Server stores `heteroSessionId` in topic metadata after each turn
- Server reads and passes `resumeSessionId` as `--resume` on subsequent turns
- Remove debug `console.log` statements from aiAgent service and sandboxRunner

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

* fix: slove some bugs

* fix: add the is dev back

* 🐛 fix: add async to handleAgentRunRequest in gatewayConnectionSrv

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:28:27 +08:00
Innei 1bc8d59922 💄 fix(chat-input): fix MentionMenu scroll area clipping caused by container padding (#14533)
💄 fix(chat-input): fix MentionMenu scroll area clipping with negative margin padding trick
2026-05-09 00:10:05 +08:00
Arvin Xu 8fab0b014e 💄 style: polish onboarding interventions and add tool result renders (#14506)
*  feat: add collapse toggle to onboarding mode switch toolbar

The dev-mode actions pill at the bottom-right of the onboarding page
covered the operation area below it. Add a chevron toggle so users can
collapse the pill down to a single icon button. Collapsed state is
persisted in localStorage so it survives reloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat: make name and avatar editable in onboarding intervention card

Lets the user override the agent's proposed identity in-place before
approving — pick a different emoji from the avatar picker, type into
the name field, and the edits flow through registerBeforeApprove ->
onArgsChange so the actual save uses the user's values.

Other changes:
- Title is now derived from the live edit state, so adding a missing
  field flips the wording from "I'll update my name" to "I'll update my
  name and avatar" without staleness
- Subtitle hint ("如果不满意,可以直接修改名字或头像") tells the user
  the card is interactive
- Test covers the edit-flush path: edits to name + emoji are observed
  via onArgsChange when the framework triggers the beforeApprove flush

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style: redesign intervention approval card as codex-style options

Drops the inline approve / reject button row in favor of a numbered
two-option layout with a single Submit at the bottom-right, mirroring
Codex's approval picker. The reject row's content is the reason input
itself (placeholder doubles as the row label) so users can type a
follow-up instruction in place; reason flows through to the existing
rejectAndContinueToolCall(messageId, reason) action.

Behavior:
- Default selection is approve; arrow keys (↑/↓) and 1/2 switch options
- Enter submits when no input is focused; reject input has its own
  Enter / ↑ handlers so typing doesn't hijack the picker
- Window-level shortcuts skip while any input/textarea/contenteditable
  is focused, so the main chat composer is never affected
- approvalMode='allow-list' adds a "Don't ask again for similar actions"
  checkbox under option 1, replacing the old split-button dropdown

Also tighten the onboarding intervention editHint copy from
"如果不满意,可以直接修改名字或头像" to "你可以直接在下方修改名字或头像"
(positive framing instead of conditional).

i18n changes (default + en-US + zh-CN):
- Add optionApprove, rememberSimilar, submit
- Repurpose rejectReasonPlaceholder as the inline reject row's placeholder
- Drop now-unused approveAndRemember, approveOnce, rejectAndContinue,
  rejectTitle keys

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style: tighten PickAgents card layout

- Move avatar and title into a single row (cardHeader) so the agent
  template title sits next to the avatar instead of below it; description
  stays as a multi-line block beneath
- Switch card border from colorBorderSecondary to colorFillSecondary so
  the card outline is visible when sitting on the elevated picker panel
- Mirror the row layout in the loading Skeleton so the shimmer matches

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(agent-marketplace): add Inspector for showAgentMarketplace and submitAgentPick

The marketplace tool was previously falling back to the generic raw-args
"等 N 个参数" header. Add per-API Inspectors:

- showAgentMarketplace: title + up to 3 localized category chips
  (sourced from existing CATEGORY_LABEL_I18N_KEYS in tool namespace);
  overflow shown as +N
- submitAgentPick: title + selected agent count

Wire AgentMarketplaceInspectors into builtin-tools/src/inspectors.ts
under AgentMarketplaceManifest.identifier and export from the package's
agentMarketplace/client surface.

i18n adds (default + en-US + zh-CN tool namespace):
- agentMarketplace.inspector.pickCount plurals
- agentMarketplace.inspector.moreCategories plurals

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style: rename showAgentMarketplace label to "Assemble agent team"

The agent narrates intent ("组建 Agent 团队" / "Assemble agent team")
rather than describing a UI surface ("打开助手市场" / "Open agent
marketplace"), which reads more naturally in the inspector header
during onboarding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style: hide chat/page view switcher in agent conversation header

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(agent-marketplace): render picked agent cards from pluginState

Adds a SubmitAgentPick Render that shows a grid of agent cards (avatar +
title + description + "already in library" tag) instead of the raw text
content the LLM consumes. Also wires the framework so custom-interaction
handlers can return structured pluginState alongside toolResultContent.

Framework changes:
- submitToolInteraction(options) now accepts a pluginState field. After
  writing toolResultContent, the chat store calls
  optimisticUpdatePluginState so the message's structured state is
  available to render components (matching how server-executed builtin
  tools persist state)
- Cloud-side wrapper in Conversation/store/slices/tool/action.ts
  forwards the new field
- customInteractionHandlers.ts SubmitToolInteractionOptions adds
  pluginState; handleAgentMarketplaceSubmit returns the install
  summaries via pluginState (same shape that built the LLM-facing text)

Marketplace changes:
- InstallMarketplaceAgentSummary gains an avatar field; the install
  helper threads marketAgent.avatar through
- New Render/SubmitAgentPick reads pluginState.summaries to draw a
  responsive card grid (already-in-library entries dimmed + tagged)
- Wire AgentMarketplaceRenders through the package's
  agentMarketplace/client surface and register under
  AgentMarketplaceManifest.identifier in builtin-tools/src/renders.ts

Workflow display labels (collapsed grouped tool row):
- Add showAgentMarketplace ("Assembled agent team" / "组建了 Agent 团队")
  and submitAgentPick ("Picked agents" / "选好了助手") to
  TOOL_API_DISPLAY_NAMES so the collapsed group no longer falls back to
  "Show Agent Marketplace" / "Submit Agent Pick" via toTitleCase

i18n adds (default + en-US + zh-CN):
- tool.agentMarketplace.render.alreadyInLibrary plurals + alreadyInLibraryTag
- chat.workflow.toolDisplayName.{showAgentMarketplace,submitAgentPick}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(web-onboarding): add UpdateDocument render with hunk diff

Replace the raw "Updated persona document (id). Applied N hunk(s)."
text with a structured per-hunk diff view rendered from args.hunks
(no executor state changes — args already carry the patches).

For each hunk render a mode label + line range chip and paint the
affected text:
- replace: removed (red border) → added (green border)
- delete: removed only
- insertAt: green block + L<line> chip
- replaceLines: green block + line range chip
- deleteLines: line range chip only (no body)

The total hunk count piggy-backs on the first hunk's label row instead
of getting its own header (the inspector header chip already shows
total + doc type, so a separate render-side header would be redundant).

i18n adds builtins.lobe-web-onboarding.updateDocument.hunkMode.{replace,
delete,deleteLines,insertAt,replaceLines} across default + en-US +
zh-CN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:08:24 +08:00
Rdmclin2 507909dc2c feat: add agent hono routes (#14535)
feat: add agent hono routes
2026-05-08 22:31:47 +07:00
YuTengjing 4721d14a81 🐛 fix: trim brief / task-template fetch overhead on home (#14516) 2026-05-08 23:06:22 +08:00
YuTengjing e1a5b27db0 feat(task): add comment tools and reparent support (#14515) 2026-05-08 22:42:10 +08:00
Innei 03621d0664 feat(explorer-tree): add generic ExplorerTree component built on @pierre/trees (#14094)
*  feat(explorer-tree): introduce generic ExplorerTree component

Scaffold a reusable tree component at `src/features/ExplorerTree/`
built on top of `@pierre/trees`. The component exposes a typed
`ExplorerTreeNode<TData>[]` input (tree or flat+parentId),
path-driven identity hidden behind an adapter, and a minimal
imperative handle (startRenaming, focus, select, setExpanded,
getSelectedIds).

Wired v1 capabilities:
- multi-select (default* + onChange), uncontrolled + ref
- DnD abstracted as `onMove(MoveEvent)` with canDrag/canDrop gates
- declarative right-click menu via `getContextMenuItems` rendered
  through the library's `renderContextMenu` slot
- inline rename via `canRename`/`onCommitRename`/`onRenameError`
- trailing row decorations via `getRowDecoration`
- built-in icon set driven by file extensions

Old `src/features/FileTree/` is tagged `@deprecated` so consumers
can migrate gradually (SkillStore, LibraryHierarchy, WorkingSidebar).
No consumers migrated in this PR — that is tracked as a follow-up.

Design spec: docs/superpowers/specs/2026-04-23-explorer-tree-design.md

* 📝 docs: add ResourceManager ExplorerTree refactor design

* ♻️ refactor(explorer-tree): use id-based tree contracts

* ♻️ refactor(explorer-tree): narrow transitional tree types

* ♻️ refactor(explorer-tree): align transitional prop contracts

* ♻️ refactor(explorer-tree): remove future-only transitional types

* ♻️ refactor(explorer-tree): support controlled id state

* 🐛 fix(explorer-tree): suppress controlled sync feedback

* 🐛 fix(explorer-tree): reconcile controlled ids on stable paths

*  feat(resource): add tree snapshot derivation

*  feat(resource): add tree mutation helpers

* 🐛 fix(resource): harden tree mutation rollback boundaries

*  feat(resource): add tree controller

* 🐛 fix(resource): guard tree controller request ordering

*  feat(resource): add tree route and bridge modules

* 🐛 fix(resource): harden tree route bridge boundaries

* ♻️ refactor(explorer-tree): expose row host events

* ♻️ refactor(resource): wire hierarchy to ExplorerTree

* ♻️ refactor(resource): remove global tree store

* 🐛 fix(resource): revalidate tree mutations by source parent

* 🐛 fix(spa): prebundle explorer tree dependency

* ♻️ refactor(sharedRendererConfig): remove unused dependencies '@pierre/trees' and '@pierre/trees/react'

Signed-off-by: Innei <tukon479@gmail.com>

* ♻️ revert(resource): remove business integration, keep ExplorerTree component only

Revert all ResourceManager business integration while preserving the
generic ExplorerTree component implementation:

- Restore ResourceManager component files to canary state
- Restore src/store/tree/ (deleted by integration commit)
- Remove src/features/ResourceManager/tree/ (controller, mutations, bridge)
- Keep src/features/ExplorerTree/ (generic component)
- Keep @pierre/trees dependency in package.json

*  feat(agent): integrate ExplorerTree into agent documents section

- Replace flat document list with ExplorerTree for 'documents' filter tab
- Convert flat AgentDocument[] to tree nodes via parentId/fileType
- Add tree node click handler (navigate/open) and context menu (delete)
- Fix height chain: ResourcesSection flex:1 -> AgentDocumentsGroup -> ExplorerTree
- Style ExplorerTree via --trees-*-override CSS vars (transparent bg, relaxed density, theme tokens)

* ♻️ refactor(resource-manager): remove outdated ExplorerTree design document

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(agent-documents): wire context menu and DnD via base-ui imperative API

- Replace nested antd Menu surface with @lobehub/ui showContextMenu, capturing right-click on the tree host directly so menu actions (rename, create, delete) survive base-ui focus restoration
- Fix DnD root drop by routing canDrop through directoryPath instead of hoveredPath, so dragging a nested file onto empty root no longer treats the hovered file row as the target zone

* ♻️ refactor(DocumentExplorerToolbar): adjust padding styles for better layout

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(useDocumentTreeOps): integrate confirmModal for delete confirmation

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(ExplorerTree): cast through unknown to satisfy antd MenuItem types

*  feat(AgentDocumentsGroup.test): add mock for DocumentExplorerTree and update tests for document count

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-08 22:34:20 +08:00
YuTengjing fcc5aa181a 🐛 fix: preserve user fields on better-auth session refetch (#14531) 2026-05-08 22:14:05 +08:00
Rdmclin2 4d934f8275 🐛 fix: telegram api lost (#14519)
* fix: bot message callback

* fix: add telegram timeout error

* Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Double escaping or unescaping'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-05-08 20:40:32 +07:00
Neko c760171f49 🐛 fix(agent-signal,types,prompts,server): should handle skill intent directly when hintIsSkill on, and reroute the source signal, or otherwise it will be hard to have skill triggers (#14526) 2026-05-08 20:14:07 +08:00
YuTengjing c7b7717faa 🐛 fix: support provider sdk type routing (#14520) 2026-05-08 20:03:08 +08:00
YuTengjing 385afbcc57 ️ perf: refresh home recents periodically and inline task status (#14518) 2026-05-08 19:32:42 +08:00
Neko d051ac008c 🐛 fix(database,userMemories): should sanitize for ` or otherwise memory search can easily fail (#14524) 2026-05-08 19:30:31 +08:00
Neko 9b2832bba9 🐛 fix(server,userMemories): should have user memory errors explicitly injected (#14525) 2026-05-08 19:30:17 +08:00
Innei 9b5cea7391 ♻️ refactor: merge agent-marketplace into web-onboarding package (#14514)
* ♻️ refactor: merge agent-marketplace into web-onboarding package

Move the standalone `builtin-tool-agent-marketplace` package into
`builtin-tool-web-onboarding/src/agentMarketplace/` as a sub-module
to reduce package sprawl and consolidate related onboarding tooling.

Also adds locale-aware fetching for onboarding agent templates:
- Accept optional `locale` param in `getOnboardingFull` TRPC endpoint
- Pass normalized i18next locale from the client fetcher
- Add unit test for locale resolution

* ♻️ refactor: integrate FollowUpChips into ChatItem and update GroupMessage components

Signed-off-by: Innei <tukon479@gmail.com>

* fix: address Codex review feedback for PR #14514

- Make getOnboardingFull input schema optional with default to preserve
  backward compatibility for callers that invoke .query() without arguments
- Parameterize SWR cache key by resolved locale to prevent cross-locale
  cache pollution in the PickAgents marketplace component

* chore: remove accidentally pushed .kagura directory and add to .gitignore

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-08 19:08:39 +08:00
Rdmclin2 f7f8bc625f 🐛 fix: tsc error (#14521)
fix: tsc error
2026-05-08 17:34:03 +07:00
YuTengjing 83bc73c2ae feat: add task template tracking (#14517) 2026-05-08 17:48:46 +08:00
Rdmclin2 75fd477bff feat: support messager (#14442)
* feat: support messagers

* chore: refactor lobeai to messager prefix

* feat: reigister messager platforms

* feat: support slack messager

* fix: verify im route redirect

* fix: link page style

* chore: optimize agent select and /agents commands

* feat:support lab switch

* feat: use same  agent select

* chore: add runtime error info

* chore: optimize error text

* feat: add slack messagger installation implementation

* chore: add more scope

* feat: add slack messager account link

* fix: open slack in a new link

* feat: optimze messager link page

* feat: optimize messager locales and bot options

* chore: optimize messager

* fix: slack integration detail

* fix: avoid taking over and fix slash commands

* chore: optimize slack app setup

* chore: update slack manifest and setup

* feat: support discrod platform

* feat: discord messger slash commands and agent picker

* chore: update discord messager

* feat: support db bot provider credentials

* chore: remove message router ensure  connected

* chore: remove notes field

* chore: add applicationId and credentails

* chore: squash db migations

* chore: remove installedAt and linkedAt field

* chore: remove messager releated env variables

* chore: remove old skill bot skill

* feat: add operationId when throwing error

* chore: abstract platform clients and registery

* chore: fix link modal message i18n and add platform definition name field

* feat: add integration detail

* feat: add platfom definition i18n files

* chore: abstract messenger router platform branches

Collapse parallel Slack/Discord slash & action paths in MessengerRouter
into a single command registry + binder hooks (replyPrivately,
extractActionFromEvent, acknowledgeCallback). Wire Discord /start by
resolving DM via openDM(authorUserId) so a public-channel slash invocation
posts the link privately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update installation and oauth process for discord and slack

* fix: telegram local button

* chore: remove messager docs

* feat: add discord installation process

* chore: remove discord bot username

* chore: adjust discord integration detail

* feat: extract platfom specific implementation

* chore: handle connection flow and redirect

* feat: add platform router for messager

* chore: move messager to agents group

* chore: update i18n files

* chore: update messager table sql

* chore: update messager sql

* fix: link with tenantId

* chore: move messger verify page to features/Messager

* chore: refactor messager verify page

* Potential fix for pull request finding 'Property access on null or undefined'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: Rebind by platform user when confirming messenger link

* chore: remove unnecessary journals

* chore: update i18n files

* fix: lint error and i18n

* fix: test cases

* chore: add lost test cases

* chore: try cpus 2

* chore: try remove optimize package import

* chore: fallback define config

* chore: try to reduce OOM

* chore: fallback

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-05-08 16:27:16 +07:00
AmAzing- 26da6b9ad4 Fix tool call timer reset on collapse and expand (#14513)
* 🐛 Preserve tool call timer across collapse and expand

* 🧪 Add coverage for execution timer reset cleanup

* 🐛 fix: clear execution timer cache after unmount
2026-05-08 15:01:53 +08:00
AmAzing- 1d4fb21885 🐛 fix: Review panel gating to use effective working directory (#14512)
* 🐛 Align working sidebar review with agent cwd

* 🐛 Align review cwd selector with GitStatus
2026-05-08 13:16:26 +08:00
YuTengjing 38c92fa04a 🐛 fix: sanitize provider tool names (#14510) 2026-05-08 11:47:07 +08:00
YuTengjing 555a375e67 🐛 fix: always recommend task templates regardless of brief count (#14508) 2026-05-08 11:17:26 +08:00
YuTengjing 6989e8f9e6 🐛 fix: sanitize Claude thinking history signatures (#14499) 2026-05-08 09:56:00 +08:00
Rdmclin2 e4d1d1fc17 👷 build(database): add messenger tables for IM bot integration (#14496)
* 👷 build(database): add messenger tables for IM bot integration

Adds three new tables to support the Messenger feature (Slack / Telegram
/ Discord / Feishu / MS Teams shared-bot integration):

- messenger_account_links: maps a LobeHub user to an IM account per
  (platform, tenant); tracks the active agent for `/switch` flows.
- messenger_installations: per-tenant OAuth install records (Slack
  workspaces, Feishu tenants, …); stores AES-GCM encrypted bot
  credentials and the installer.
- system_bot_providers: deployment-wide App-level bot credentials
  (one Discord App / Telegram bot / Slack App per deployment),
  replacing the env-var-based config.

All sensitive credentials are encrypted via KeyVaultsGateKeeper, the
same gatekeeper used by `agent_bot_providers`. SQL is idempotent
(`IF NOT EXISTS` / `DROP CONSTRAINT IF EXISTS`) per repo convention.

Includes models with full test coverage. Schema and migration only —
no router / service wiring in this PR.

* 🐛 fix(database): bridge stale messenger_account_links missing tenant_id

Some envs deployed a pre-squash version of the messenger migrations
where `messenger_account_links` was created without `tenant_id` and
used the legacy 2-column unique indexes. CREATE TABLE IF NOT EXISTS is
a no-op on those tables, so the new 3-column unique index then fails
with `column "tenant_id" does not exist` (PG 42703).

Add the same bridge logic the original 0102 migration carried — ALTER
ADD COLUMN IF NOT EXISTS for tenant_id and DROP INDEX IF EXISTS for the
two legacy indexes. Idempotent on fresh DBs.

* Revert "🐛 fix(database): bridge stale messenger_account_links missing tenant_id"

This reverts commit d5232564e4.
2026-05-08 01:10:34 +07:00
Arvin Xu 026c79a4c2 💄 style: simplify onboarding agent identity intervention card (#14505)
* 💄 style: simplify onboarding agent identity intervention card

- Drop redundant "Onboarding approval" eyebrow, "Agent name"/"Agent avatar" field grid, and "Applies to" target chips — the description above already conveys scope, and the avatar+name preview already shows the new identity
- Rephrase title to first-person agent voice ("I'll update my name and avatar") so the card reads as the agent announcing what it will do, not a generic admin form
- Remove the now-dead applyHint line under the avatar
- Prune unused i18n keys (eyebrow / applyHint / name / emoji / targets / targetInbox / targetOnboarding) across default + en-US + zh-CN
- Update webOnboarding intervention test to match the simplified card

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix: use field-aware title for partial saveUserQuestion approvals

The manifest routes name-only and emoji-only saveUserQuestion calls through the same intervention as the both-fields case, but the previous title hardcoded "I'll update my name and avatar". An emoji-only approval would over-promise a rename that never happens.

Pick titleNameOnly / titleAvatarOnly / title based on which fields are actually pending; cover all three branches in webOnboarding.test.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style: drop redundant scope description from onboarding intervention

The field-aware title already says exactly what's about to change ("I'll
update my name" / "...avatar" / "...name and avatar"); the secondary line
explaining that the change applies to Inbox + the current onboarding chat
was extra reading without new information for someone mid-onboarding.

Remove the description Text + i18n key (default + en-US + zh-CN) and
collapse the now-single-child header Flexbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:02:12 +08:00
Arvin Xu 1e2782ece4 🐛 fix(gateway): keep input loading on through execAgentTask round-trip (#14503)
* 🐛 fix(gateway): keep input loading on through execAgentTask round-trip

The Gateway branch in `sendMessageInternal` completed the parent
`sendMessage` op before awaiting `executeGatewayAgent`, so during the
`execAgentTask` network round-trip no operation was running. The send
button briefly flickered back to "send" until the child
`execServerAgentRuntime` op started.

Move `completeOperation` to after `executeGatewayAgent` resolves —
by then the child op is already running, so loading state never drops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(gateway): hand off parent op inside executeGatewayAgent

Make `executeGatewayAgent` accept an optional `parentOperationId` and
complete it the instant phase-1 init finishes — right after the child
`execServerAgentRuntime` op starts and the assistant message is
associated. Previously the caller had to call `completeOperation` after
`await executeGatewayAgent(...)` returned, which was fragile: any future
`await` added between the child startOperation and the function return
would silently extend the parent op's lifetime past phase-1.

Also wires `parentOperationId` through to `startOperation` so the
parent/child lineage is recorded on the new op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(brief): persist agentId so brief cards render the producing agent's avatar

`BriefCard` only renders the agent avatar when the enriched `brief.agent`
is non-null, which in turn requires `briefs.agentId` to be set. Several
brief creation paths (task lifecycle synthesize/error/review, and the
agent-driven `lobe-brief` tool runtime) were inserting briefs without
`agentId`, leaving the avatar slot empty in the Daily Brief card.

Pass `assigneeAgentId` from the task in `TaskLifecycleService` and
`context.agentId` from the tool execution context in the brief runtime.
No backfill — internal testing only, historical rows stay null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(gateway): honor stop clicks during phase-1 init

With the parent `sendMessage` op kept running through the
`execAgentTask` round-trip (so the input loading state stays on),
clicking Stop now reaches `cancelOperation(sendMessage)` mid-await but
`executeGatewayAgent` was unaware of the abort: the request finished,
the server task got created, the WS opened, and the agent ran despite
the cancel.

Fixes:

- Plumb the parent op's AbortSignal into `aiAgentService.execAgentTask`
  so the fetch itself aborts in-flight when cancel arrives during the
  round-trip.
- After every await in phase-1 init, re-check `signal.aborted` and bail
  out — the server task may already exist if cancel arrived after the
  request resolved, so fire `interruptTask` best-effort before throwing.
- In the caller catch path, skip `failOperation` when op status is
  already `cancelled` so we don't clobber the user-cancelled state with
  `failed`.

Adds a regression test that pre-aborts the controller, awaits
`executeGatewayAgent`, and asserts the signal is forwarded, the server
task is interrupted, and the child op / message association / WS
connect / parent completion are all skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(review): add branch-compare diff mode with base ref picker

Introduces a Branch mode in the agent Review panel that diffs the current
HEAD against the remote default branch (resolved via `refs/remotes/origin/HEAD`,
overridable via a per-repo base picker). Pulls the comparison data through a
new `getGitBranchDiff` IPC that streams `git diff base...HEAD` and reuses the
existing per-file split + size-cap path, plus `listGitRemoteBranches` for the
picker. Renders a GitHub-style `base ▾ ← head` label with shrink/ellipsis
behaviour, swaps the loading spinner for `NeuralNetworkLoading`, and persists
the user's base override in localStorage keyed by working directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-profile): hide right-panel toggle for heterogeneous agents

Heterogeneous runtimes (Claude Code, Codex, etc.) own their own toolchain
and don't surface the LobeHub right-panel content, so the toggle button is
a dead-end in their profile header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:01:26 +08:00
Innei b5ddac56dc 🐛 fix(assistant-group): pass per-segment content overrides to MessageContent (#14504)
When assistant content blocks are split into answer and workflow segments,
each segment now receives explicit `contentOverride` and `hasToolsOverride`
props so that the rendered markdown matches the segment's own content
instead of all segments reading the same store subscription.
2026-05-08 01:51:11 +08:00
Innei ad0da3753e feat(kb-tool): integrate BM25 search and docs_* read for inline documents (#14494)
*  feat(kb-tool): integrate BM25 search and docs_* read for inline documents

- searchKnowledgeBase now returns inline documents (BM25 over documents.content)
  alongside file chunks (vector). Inline custom/document records created via
  createDocument or `lh kb create-doc` are now discoverable through the agent tool.
- readKnowledge accepts both file_* and docs_* IDs. docs_* reads documents.content
  directly (no S3 lookup, no parse).
- chunkRouter.semanticSearchForChat: dual-path with Promise.allSettled — failures
  on either path no longer kill the whole call; surfaced via new `errors` field.
- formatSearchResults renders <files> and <documents> sections separately.

Fixes LOBE-8606
Fixes LOBE-8608

* style(TitleSection): add border radius to title input field

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(kb-tool): preserve search-path errors in zero-result responses

When semanticSearchForChat returns no hits but includes errors (e.g. vector
search fails and BM25 finds nothing), use formatSearchResults which renders
error notes, instead of promptNoSearchResults which drops them silently.

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-08 01:32:42 +08:00
Arvin Xu e6905fe0fd feat(agent-sidebar): move tasks from welcome card to sidebar list (#14500)
Replace the inline `AgentTaskList` card on agent and inbox welcome
screens with a dedicated `Tasks` section in the agent sidebar that
groups items by status (Pending review / Backlog / In progress).
Sidebar fetch is scoped to active statuses only — `done` and
`canceled` are neither pulled nor rendered, and use a separate SWR
key from the kanban page so the two views don't trample each other's
state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:32:01 +08:00
Hardy a9d2110565 🐛 fix: onFinish never called when browser tab is backgrounded during SSE streaming (#14461)
🐛 fix: remove rAF animation blocking onFinish when tab is backgrounded

Replace await-on-animation with synchronous flushQueue() to prevent
background tab throttling from stalling chat completions, MCP tool
calls, and agent loop continuation.
2026-05-08 00:10:31 +08:00
Arvin Xu e4d5f69b27 ♻️ refactor(agent): migrate remaining /api/agent routes to Hono (#14478)
* ♻️ refactor(agent): migrate remaining /api/agent routes to Hono

Move the static `route.ts` handlers under `src/app/(backend)/api/agent/`
into the existing Hono app at `src/server/agent-hono/`, leaving only the
SSE `stream` endpoint as a Next.js route. Behavior, URLs, and auth
semantics are unchanged.

- New middlewares: `qstashAuth` (QStash sig only) and `bearerSecretAuth`
  (factory for arbitrary `Bearer <secret>` checks)
- Migrated handlers: `run`, `webhooks/bot-callback`, `gateway`,
  `gateway/start`, `gateway/callback`, `webhooks/[platform]/[[...appId]]`
- `gateway/callback` keeps inline auth so the disabled-feature 204 still
  short-circuits before any auth check
- `gatewayCron` keeps `next/server`'s `after()` for the 10-min poll loop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🧪 test(agent-hono): cover migrated route handlers and new middlewares

Add unit tests for the handlers and middlewares introduced by the
/api/agent → Hono migration. Each test uses the same hand-built Hono
Context stub pattern as `toolResult.test.ts` (vitest can't resolve the
hoisted `hono` package, so a real Hono Context isn't available in
tests).

Coverage:
- middlewares/qstashAuth (sig pass/fail → next called/not, body forwarded
  to verifier)
- middlewares/bearerSecretAuth (503/401/200 paths, lazy secret eval)
- handlers/runStep (validation, lock 429 + Retry-After, success shape,
  upstash-retried header forwarding)
- handlers/botCallback (validation + service delegation + 500 on throw)
- handlers/gatewayCallback (disabled-feature 204, auth, zod validation,
  state.status → BotRuntimeStatus mapping)
- handlers/gatewayStart (start/restart paths, stop-before-ensure
  ordering, 500 on failure)
- handlers/platformWebhook (param validation, raw request passthrough)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:37:23 +08:00
LiJian a372acd50d feat: add lobeAgents markdown tag for inline agent card rendering (#14495)
*  feat: add lobeAgents markdown tag for inline agent card rendering

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

* 📝 docs(agent-management): instruct LLM to render lobeAgents card after agent operations

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

* 🐛 fix(lobe-agents): support single-quoted attrs and preserve trailing paragraph siblings

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:37:20 +08:00
YuTengjing 0af5e51477 🐛 fix: sanitize assistant media in Responses input (#14497) 2026-05-07 23:26:22 +08:00
LiJian 40f0557158 feat(agent-management): render clickable agent card after createAgent (#14493)
 feat(agent-management): render clickable agent card after createAgent tool execution

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:59:07 +08:00
YuTengjing 62f06540ba 🐛 fix: show notification settings in desktop (#14491) 2026-05-07 19:58:53 +08:00
YuTengjing 43b064f803 feat: add RecommendTaskTemplates UI and default noop router (#14488) 2026-05-07 19:14:08 +08:00
YuTengjing 8e8a463a05 🐛 fix: use runtime config to hide LobeHub provider toggle (#14487) 2026-05-07 19:07:05 +08:00
Neko decc25554e 🐛 fix(memory-user-memory): should have nullable when parsing activities (#14489) 2026-05-07 19:04:12 +08:00
CanisMinor 1c8ec2681c 💄 style: update brief template style (#14483)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-07 17:34:47 +08:00
Innei 0a32fbc737 🐛 fix(desktop-overlay): hide model picker and stabilize panel enter (#14484) 2026-05-07 16:39:32 +08:00
sxjeru 7fc41a9677 🐛 fix: add provider settings normalization & add Gemma 4 models (#13313)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-07 12:46:19 +08:00
AmAzing- 22c880763d ️ revert: remove e2e topic group expansion workaround (#14480) 2026-05-07 12:16:24 +08:00
Arvin Xu d324736edf 💄 style: polish onboarding agent welcome and add web-onboarding tool UI (#14475)
* 💄 style: polish onboarding agent welcome and name suggestions

- Float NameSuggestions above ChatInput (out of greeting message), match width via WideScreenContainer
- Compact suggestion cards: emoji and name on one row, smaller padding, ellipsis prompt
- Migrate suggestion data from i18n to a typed config (`nameSuggestions.config.ts`) with EN/ZH content
- Expand pool to 50 differentiated names; ZH uses native Chinese names, EN uses English; sample 3 random items per group, refresh excludes current ids
- Click a card to fill ChatInput instead of sending immediately
- Tighten welcome footer copy in EN/ZH

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style: refine onboarding name suggestions and click-to-fill flow

- Click a suggestion fills ChatInput via editor.setDocument + focus instead of sending immediately
- Append localized avatar hint ("Use {emoji} as the avatar." / "头像用 {emoji}。") to the filled message
- Expand suggestion pool to 100 with bilingual EN/ZH content; mix 2/3/4-char Chinese names; rebalance emoji↔name pairings; tone the 4-char ZH names toward modern/youthful phrasing
- Update NameSuggestions.test.tsx to mock editor.setDocument/focus and i18n interpolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(builtin-tools): add web-onboarding tool inspectors and write document render

- Add Inspector components for FinishOnboarding / ReadDocument / SaveUserQuestion / UpdateDocument / WriteDocument under @lobechat/builtin-tool-web-onboarding/client
- Add Render component for WriteDocument
- Wire WebOnboardingInspectors and WebOnboardingRenders into the central builtin-tools registries (inspectors.ts / renders.ts)
- Add tool display names (saveUserQuestion → "Recorded info", writeDocument → "Wrote a document") to AssistantGroup constants and chat locale
- Add plugin locale keys for docType (User Persona, SOUL.md) and pluralized inspector counters (chars / changes / interests); shorten saveUserQuestion API name to "Save"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix: guard resolveNameSuggestion against undefined locale

When useTranslation is mocked without an i18n.language (e.g. Conversation.test.tsx), locale came in undefined and resolveNameSuggestion crashed on `.toLowerCase()`. Treat missing/unknown locales as a fallback to en.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:32:32 +08:00
Arvin Xu 608498a950 feat(agent): inactivity watchdog finalize endpoint + agent-hono migration (#14476) 2026-05-07 09:54:47 +08:00
Innei 5e1a35f259 🐛 fix(conversation): reduce streaming re-renders with reference stabilization and self-subscribing components (#14470)
* 🐛 fix(conversation): reduce streaming re-renders with reference stabilization and self-subscribing components

- Add stabilizeReferences utility to pin unchanged subtrees to previous identity after parse()
- Make Tool, Tools, and MessageContent self-subscribe via store selectors instead of receiving data as props
- Stabilize handleExpandedChange and expandedKeys in WorkflowCollapse with useCallback/useMemo
- Add selectors: findBlockById, getToolsInBlock, getToolInBlock, getBlockContent, getBlockHasTools

* 🔧 chore(agent-mock): update todo-write-stress test case

* feat: refactor todo-write-stress case to utilize lobe-gtd API for task management and enhance workflow with structured plans and todos

- Updated tool steps to replace previous bash commands and file operations with lobe-gtd API calls for creating and updating todos and plans.
- Introduced structured plans for various phases of the migration process, enhancing clarity and organization.
- Implemented a breathing step to simulate processing between tool-call batches.
- Enhanced the overall flow of the todo-write-stress case to reflect a more realistic and organized task management approach.

refactor: optimize ContentBlocksScroll component with virtualized list for improved performance

- Added CSS styles to enable content visibility auto for off-screen workflow items, preserving React state while optimizing rendering.
- Updated Flexbox component to conditionally apply virtualized list styles based on the variant prop, enhancing layout performance.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(conversation): remove virtualized list styles to improve rendering performance

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(conversation): address codex streaming review feedback

* ♻️ refactor(conversation): use query structural sharing helper

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-07 02:29:31 +08:00
Innei 6b010c8380 🐛 fix(editor-runtime): add mutation handlers for post-save synchronization (#14469)
* 🐛 fix(editor-runtime): add afterMutateHandler for post-mutation synchronization

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(editor-runtime): enhance beforeMutateHandler with context and add meaningful content check

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(editor-runtime): improve data source validation and streamline command dispatch logic

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(editor-runtime): add test for Page Agent editTitle behavior without sending content or editorData

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(editor-runtime): update LiteXML node extraction to include attributes and improve error logging

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix: use namespace import for GeneralChatAgent to fix vi.spyOn in tests

vi.spyOn on a module namespace object requires the production code to
access the class through the same namespace. Destructured imports capture
a direct binding that bypasses the spy, causing "Class constructor
GeneralChatAgent cannot be invoked without 'new'" in tests.

* 🐛 fix: replace vi.spyOn on class constructor with vi.mock for GeneralChatAgent

vi.spyOn wraps a class with a plain function that loses [[Construct]]
semantics in ESM, causing "Class constructor GeneralChatAgent cannot be
invoked without 'new'". Replace with vi.mock + hoisted mock constructor
that properly tracks calls while preserving new-ability.

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-07 02:21:35 +08:00
YuTengjing ead5631bab 🐛 fix: preserve nested router runtime id (#14474) 2026-05-07 01:45:12 +08:00
YuTengjing ddd5c20836 💄 style: add grok-4.3 to LobeHub-hosted xAI models (#14446) 2026-05-07 00:49:54 +08:00
YuTengjing c51835193f 🐛 fix: stabilize xAI Responses API tools (#14462) 2026-05-07 00:11:44 +08:00
Arvin Xu 0c375e4428 💄 style: update heterogeneous agent ChatInput placeholder text (#14454)
* 💄 style: update heterogeneous agent ChatInput placeholder text

Change 'Ask {{name}} to do a task...' to 'Describe a task or ask a question to {{name}}' for a more natural prompt consistent with Claude Code style.

* fix: also update TypeScript locale source for sendPlaceholderHeterogeneous

* fix: unify casing for popup window labels and simplify folder chooser text
2026-05-06 23:38:53 +08:00
YuTengjing 58cda8a645 🐛 fix: persist home sidebar collapse state (#14473) 2026-05-06 23:32:14 +08:00
AmAzing- 65ba4ad435 🐛 fix(e2e): expand visible topic groups in E2E steps (#14472)
🧪 Expand visible topic groups in E2E steps
2026-05-06 22:27:03 +08:00
AmAzing- 41ffd1e0d3 🧪 Fix streaming executor agent spy tests (#14471) 2026-05-06 21:32:51 +08:00
LiJian 02767bac55 🐛 fix: resolve template variables in server-side (execAgent) context engine (#14468)
In execAgent/bot mode, `serverMessagesEngine` is called from
`RuntimeExecutors.ts` without several `{{VARIABLE}}` placeholders that
the client-side `contextEngineering.ts` correctly resolves via stores
and lambdaClient. This caused literal `{{CREDS_LIST}}`, `{{username}}`,
`{{language}}`, `{{memory_effort}}`, `{{sandbox_enabled}}`, and
`{{CRON_JOBS_LIST}}` strings to leak into LLM prompts.

Fix: resolve each missing variable before building `contextEngineInput`:
- `{{username}}` / `{{language}}`: `UserModel.getInfoForAIGeneration()`
- `{{sandbox_enabled}}`: check `lobe-cloud-sandbox` in enabled tools
- `{{memory_effort}}`: read from `agentConfig.chatConfig.memory.effort`
- `{{CREDS_LIST}}`: `MarketService.market.creds.list()` (lobe-creds gate)
- `{{CRON_JOBS_LIST}}`: `AgentCronJobModel.findWithPagination()` (lobe-cron gate)

All fetches are best-effort (try/catch → empty string fallback) so a
transient error never breaks agent execution.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:09:22 +08:00
Innei be5d61d40a feat(desktop): add app tray visibility setting (#14463)
*  feat(desktop): add app tray visibility setting

* ♻️ refactor(desktop): move tray setting to appearance
2026-05-06 18:13:23 +08:00
Rylan Cai 282b20c454 🐛 fix context compression threshold config (#14439) 2026-05-06 17:08:10 +08:00
AmAzing- cc506c036d 🐛 fix: task breadcrumb title truncation (#14460)
 Fix task breadcrumb title truncation
2026-05-06 16:46:23 +08:00
LiJian 5fca91a488 🐛 fix: inject user response language into task summary chains (#14459)
Pass the user's preferred response language (from settings) to
chainTaskTopicHandoff and chainGenerateBrief so that task run titles
and briefs always output in the user's configured language instead of
following the agent's content language.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:41:20 +08:00
Neko c3530ad221 🐛 fix(app,agent-signal): for skills, target to skill document, and auto refresh (#14457) 2026-05-06 16:19:36 +08:00
Zhijie He 8b8b0f0579 💄 style: add step-image-edit-2 support (#14329)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-06 15:51:54 +08:00
YuTengjing 958bf52978 🐛 fix: preserve visual refs for bot uploads (#14456) 2026-05-06 15:38:39 +08:00
René Wang 480d4b2b4e 📝 docs: add May 4 weekly changelog (#14434) 2026-05-06 14:48:01 +08:00
YuTengjing 4d00c22e7f 🐛 fix: handle unsupported xAI parameters (#14445) 2026-05-06 14:45:09 +08:00
Innei f30d9da5a9 feat(agent-mock): add agent mock devtools with playback & fixture viewer (#14436)
* 📦 feat(agent-mock): scaffold package skeleton

* 🔧 chore(agent-mock): align deps + add vitest config

*  feat(agent-mock): add core types

*  feat(agent-mock): add chunkSplitter with code-point safety

*  feat(agent-mock): map ExecutionSnapshot → MockEvent[]

*  feat(agent-mock): add defineCase / llmStep / toolStep / errorStep DSL

*  feat(agent-mock): add snapshotToMockCase helper

*  feat(agent-mock): add todo-write-stress builtin case + registry

*  feat(agent-mock): add generator registry + tool-stress generator

*  feat(agent-mock): add 4 more builtin cases (long-reasoning, mixed, error, subagent)

*  feat(agent-mock): add subagent-tree + long-reasoning generators

*  feat(agent-mock): add MockPlayer state machine + step navigation

*  feat(agent-mock): add __agentMockSilent flag + signal bridge guard

*  feat(agent-mock): add executeMockStream with side-effect gating

*  feat(agent-mock): add dev-only devClearMockTopics TRPC procedure

*  feat(agent-mock): add dev API to list/read .agent-tracing snapshots

*  feat(agent-mock): add agentMockStore zustand

*  feat(agent-mock): add useMockCases hook

*  feat(agent-mock): add useAgentMockPlayer hook

*  feat(agent-mock): add useMockTopicCleanup hook

*  feat(agent-mock): add Fab entry component

*  feat(agent-mock): add Modal shell with tab bar

*  feat(agent-mock): add CaseList sidebar with search + groups

*  feat(agent-mock): add MiniBar floating playback controls

*  feat(agent-mock): add StatusGrid component

*  feat(agent-mock): add Controls (play/pause/step/speed)

*  feat(agent-mock): add ProgressBar

*  feat(agent-mock): add TargetPicker

*  feat(agent-mock): compose PlayerPanel

*  feat(agent-mock): add TimelinePanel + virtualized EventRow

*  feat(agent-mock): add read-only FixtureViewer with copy button

*  feat(agent-mock): add SettingsPanel with toggles + clear topics

* ♻️ refactor(agent-mock): address quality review (stable itemContent, type-safe error handling, clipboard catch)

*  feat(agent-mock): wire entry component (FAB + Modal + MiniBar)

*  feat(agent-mock): mount AgentMockDevtools in SPAGlobalProvider

* ♻️ refactor(agent-mock): switch Modal to imperative createModal API

* 🐛 fix(agent-mock): use close() + onOpenChangeComplete to preserve motion exit animation

* work

Signed-off-by: Innei <tukon479@gmail.com>

* minify

Signed-off-by: Innei <tukon479@gmail.com>

* 💄 refactor(agent-mock): rebuild devtools UI/UX with mono palette and IA reorg

Replace the in-modal sidebar + tab strip + MiniBar with a Fab-anchored
draggable Popover (case picker, transport, replay/loop, scrubbable progress,
stop, Open DevTools) and a token-driven Modal layout (two-row header,
Segmented view tabs, StatsStrip, sticky TransportBar). Wire EventRow and the
progress bars to seekToEventIndex (resolves the prior TODO), swap alert() for
toast.warning, persist loop and popover position to localStorage.

* work

Signed-off-by: Innei <tukon479@gmail.com>

* 🧹 chore(agent-mock): remove replay debug logs

* 👷 build: add @google/genai to pnpm allowBuilds

Fixes ERR_PNPM_IGNORED_BUILDS in CI — pnpm v11 blocks install
when a dependency with install scripts is not in the allowBuilds list.

* 🐛 fix: resolve TS type errors in useAgentMockPlayer

- parentMessageId: coerce `undefined` to `null` to match `string | null`
- threadId: coerce `null` to `undefined` for cancelOperations param

* ♻️ refactor: revert ConversationArea & sync-import AgentMockDevtools

- ConversationArea: restore messageMapKey(context), avoid needless field spread
- SPAGlobalProvider: switch AgentMockDevtools to sync import (dev-only, no need to lazy)

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-06 14:32:59 +08:00
LiJian 831b4ee5ca 🐛 fix: execAgent runtime should have agent management tools (#14371)
* 🐛 fix: add server runtime for lobe-agent-management tool

- Add `agentManagement.ts` server runtime in `serverRuntimes/`
- Implement all 9 API methods: `createAgent`, `updateAgent`, `deleteAgent`,
  `getAgentDetail`, `duplicateAgent`, `updatePrompt`, `installPlugin`,
  `searchAgent`, `callAgent`
- Uses `AgentModel` from `@lobechat/database` for agent CRUD
- Uses `DiscoverService` for marketplace search in `searchAgent`
- `callAgent` with `runAsTask: true` returns `execTask` state for task system
- Register `lobe-agent-management` in `serverRuntimes/index.ts`

Fixes LOBE-8434

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: address review feedback for agent-management server runtime

- callAgent: always use task path on server (no `registerAfterCompletion` available for synchronous execution)
- installPlugin: create `user_installed_plugins` DB record via PluginModel so manifest is discoverable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 13:56:19 +08:00
Neko c744eab116 feat(agent-signal,database,app,server): agent signal activities during nightly self-reflection will now push to briefs (#14437) 2026-05-06 13:33:54 +08:00
Rdmclin2 7697399da8 feat: optimize line bot (#14448)
* chore: optimize line config schema

* chore: optimize form render order

* chore: update i18n files
2026-05-06 11:50:31 +07:00
LobeHub Bot 05a9eae504 🌐 chore: translate non-English comments to English in edge-config (#14453) 2026-05-06 11:56:09 +08:00
Arvin Xu cc1e0d29d3 💄 style(brief-card): mute icon for resolved briefs on home page (#14452)
* 💄 style(brief-card): mute brief icon when brief is resolved

Resolved briefs now render the leading icon with muted gray colors instead
of the type's accent color, matching the existing "已标记为已解决" pill so the
card visually reads as inactive at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(page-agent): add custom Render for modifyNodes tool

Wires page-agent renders into the central registry and adds a per-operation
list view for modifyNodes (action icon, position chip, litexml preview, and
per-op success/error from pluginState.results), replacing the JSON fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(brief): set trigger='task' on briefs created from task lifecycle

Populate the existing `trigger` column on briefs emitted by the task
lifecycle (error, synthesized topic, auto-review pass/retry/force-pass)
and the heartbeat watchdog (workflow + tRPC), so consumers can filter
briefs by source module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief-card): show only the producing agent avatar

Stop fetching every agent in the task tree for brief cards. The stacked
Avatar.Group looked noisy for tasks with multiple subagents and didn't
convey ownership; render a single avatar for the agent that produced
the brief instead (`brief.agentId`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:20:11 +08:00
Arvin Xu 0e6eba61a9 feat(hetero-agent): server-side aiAgent.heteroIngest / heteroFinish + persistence handler (#14444)
*  feat(hetero-agent): add aiAgent.heteroIngest / heteroFinish procedures (LOBE-8535 phase 2a)

Wires `lh hetero exec` producer streams into the existing StreamEventManager
fanout: events flow CLI → tRPC → Redis Stream → gateway WS → renderer with
the same wire shape as gateway-driven runs.

- Reconcile server StreamEvent.type with @lobechat/agent-gateway-client's
  AgentStreamEventType so tool_execute / tool_result land natively
- HeterogeneousAgentService skeleton with sequential publish (preserves
  stepIndex ordering) + terminal agent_runtime_end fallback on finish
- Inline Zod schemas on aiAgentProcedure; topicId required (operationId
  reverse-lookup unreliable per LOBE-8516 design decision)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(hetero-agent): add HeterogeneousPersistenceHandler — server-side DB writes (LOBE-8535 phase 2b)

Mirrors src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts
(1.8k lines) for the DB concerns. Renderer keeps its own copy for
desktop-host concerns (IPC, store dispatch, notifications); cloud / CLI
ingest goes through this handler instead.

- 3-phase tool persist: pre-register tools[] → create role:'tool' message
  → backfill result_msg_id (mirrors persistToolBatch lines 319–411)
- Subagent threads: lazy-create on first tagged chunk + per-turn assistant
  chaining + finalize on parent tool_result with terminal assistant
- Step boundaries: stream_start { newStep: true } flushes prior content
  and chains a new assistant off the last tool message
- Per-turn metadata persistence (step_complete phase=turn_metadata)
- Module-level state map keyed on operationId; idempotency via
  (stepIndex, type, timestamp). Multi-replica caveat documented — phase 3
  sandbox owns the endpoint per-instance so sticky routing is implicit.

Tests:
- 13 unit tests with fake-models harness covering bootstrap, idempotency,
  3-phase persist, step boundaries, subagent lifecycle, terminal events
- 2 fixture-driven tests replaying .heerogeneous-tracing/cc-streaming.json
  (502 events, 71 tool uses) end-to-end with idempotency assertions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): restore runtime imports after lint auto-fix

ThreadStatus / ThreadType / AgentRuntimeErrorType are used as values, not
just types — the post-commit linter incorrectly converted the import to
`import type`, which broke the build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(hetero-agent): full renderer event-branch parity + session resume (LOBE-8535 phase 2b' + 2c)

Renderer-parity additions to HeterogeneousPersistenceHandler:
- Echo-suppression: when CC streams an AuthRequired error string into
  `content` BEFORE emitting the structured error, the assistant ends up
  with both. Mirror the renderer's `shouldSuppressTerminalErrorEcho` logic
  (lines 113–130 of heterogeneousAgentExecutor.ts) so we keep only the
  structured error in those cases. Trigger conditions: `AuthRequired` code
  or explicit `clearEchoedContent` flag.
- 34 new branch-coverage tests against every event variant the renderer
  dispatches on (step_complete phases, stream_start with/without newStep,
  stream_chunk text/reasoning/tools_calling × main/subagent, all no-op
  variants, terminal error echo handling, subagent edge cases).

Phase 2c — session id persistence + resume helper:
- ChatTopicMetadata.heteroSessionId docstring updated: it's now the shared
  field for desktop and cloud paths (was tagged "desktop only").
- handler.finish() now accepts `sessionId` and writes it via
  TopicModel.updateMetadata (merges, preserves runningOperation peer).
- HeterogeneousAgentService passes sessionId through, exposes
  `getHeterogeneousResumeSessionId(topicId)` helper for phase 3 cloud
  sandbox routing to inject `--resume <id>` on the next CLI spawn.
- 9 tests covering happy path, missing session id, error result still
  persists, peer-field preservation, updateMetadata failure isolation,
  and the resume helper's lookup paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): collision-safe idempotency key + mark-processed-after-success + portable fixture (PR #14444 review)

Three issues from PR review:

1. **Idempotency key collision** — the old `(stepIndex, type, timestamp)`
   triple collided when CC bursts multiple `stream_chunk` events through
   the same step within a single `Date.now()` millisecond. Later chunks
   got dropped as duplicates → silent assistant truncation. Now keys
   include a stable FNV-1a fingerprint of `event.data`, so distinct
   payloads stay distinct even at the same timestamp.

2. **Mark-processed-before-handle** — `processedKeys.add(key)` ran BEFORE
   `handleEvent`, and ingest swallowed throws. A transient DB error in
   any per-event write was silently lost: the event was marked done,
   the BatchIngester acked OK, retries skipped it, content was gone.
   Now: mark only after successful handling + propagate throws all the
   way to the BatchIngester so the batch retries. Idempotency map
   dedupes the events that already succeeded earlier in the batch.

   Knock-on: removed every `.catch(log)` from per-write paths. Renderer's
   "log + continue" posture doesn't fit the server (authoritative for
   cloud runs, silent partial writes diverge DB from WS view).

3. **Portable fixture** — `.heerogeneous-tracing/cc-streaming.json` is
   gitignored and missing in CI, so the fixture-driven test couldn't run.
   Replaced file IO with a synthetic stream that captures the same
   characteristics (multi-step, bursty same-millisecond text chunks,
   tool_use → tool_result pairs, step boundaries, terminal event). The
   synthetic fixture is also more meaningful — it has explicit assertions
   about chain-shape and bursty-text dedupe correctness.

Tooling adjustments to support the new contract:

- `persistToolBatch` restructured: payloads de-dup by id (so retries
  don't duplicate); `persistedIds` populated only AFTER successful
  per-tool create; phase 1 + phase 3 always run (idempotent re-writes)
  so a partial-failure retry can complete missed phase 3 backfills.
- `ensureSubagentRun`: thread/user/first-assistant create errors throw
  out instead of returning `undefined` and dropping the run.
  `ThreadModel.create` already uses `onConflictDoNothing` on id, so
  retrying the same generated id is safe.

Tests added (69 hetero-agent tests, was 66):
- Bursty same-timestamp distinct-content text chunks → all preserved
- Mark-processed-after-success retry contract (transient flake recovery)
- Synthetic fixture replays a multi-step CC-shaped run with chain-shape
  + idempotency + partial-batch retry assertions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:30:19 +08:00
Arvin Xu 3e8016b502 🔨 chore(cli): update cli version to 0.0.11 (#14451)
🔨 chore: update cli version to 0.0.11

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:26:28 +08:00
Arvin Xu 970733aaeb ♻️ refactor(time): extract useActivityTime hook and move time keys to common (#14443)
♻️ refactor(time): extract useActivityTime hook and move time keys to common namespace

- Add `useActivityTime` hook wrapping `formatActivityTime` with i18n built in
- Move `time.formatThisYear/formatOtherYear/today/yesterday` from `discover` to `common` namespace
- Refactor chat header (hetero-agent), Task Activities, memory/home time, and Comment/Topic cards to use the hook so they show relative time (`5 minutes ago`) within 24h and absolute date afterwards
- Switch `PublishedTime` and `AgentTaskItem` to consume time keys from `common`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:26:10 +08:00
Arvin Xu c72b1ee698 🐛 fix(changelog): replace gray-matter with browser-compatible frontmatter parser (#14435) 2026-05-06 10:13:46 +08:00
Arvin Xu 7bf923d762 🐛 fix(agent-runtime): finalize trace snapshot on error path (#14440)
* 🐛 fix(agent-runtime): finalize trace snapshot on error path

Propagated errors from RuntimeExecutors (e.g. `markPersistFatal` from a
parent_id FK violation) used to skip snapshot finalization entirely:
the success-path `finalizeSnapshot` block lived inside the try, so the
catch threw without writing the canonical
`agent-traces/<agentId>/<topicId>/<op>.json`. The partial sat orphaned
at `_partial/<op>.json`, the final S3 path returned 404, and the failed
op was invisible in the trace bucket while still showing as `status:
'error'` in Redis. (LOBE-8533)

Extract the finalize block into `finalizeSnapshotForOperation` and call
it from both the success branch and the error catch. The error call
synthesizes a failed step (the real one never reached
`appendStepToPartial` — it threw before the partial push), so step
counts stay aligned with the assistant message that triggered the call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test: align expected strings with English-only labels and fix mobile router import sort

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-runtime): dedupe failed-step append and trust finalized step count

- finalizeSnapshotForOperation now merges the error event into an existing
  step record when the synthetic failedStep collides with one already
  written by the success-path append (e.g. saveAgentState or queue
  scheduling threw post-append). Prevents duplicate stepIndex entries
  that corrupt ordering and per-step metrics in trace reconstruction.
- totalSteps is derived from the finalized step array instead of
  state.stepCount, so the synthesized failed step is reflected in the
  snapshot total (Redis-loaded stepCount lags by one on the error path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:26:27 +08:00
Arvin Xu 10300ba0e1 feat(hetero-agent): support multimodal input across CLI / shared spawn / desktop (#14433)
*  feat(hetero-agent): support multimodal input across CLI / shared spawn / desktop

`spawnAgent` and `lh hetero exec` could only take a flat string prompt, so
attaching images required bypassing the shared layer (which is what desktop
actually did). This adds a unified `AgentPromptInput` shape — string sugar or
an array of text/image content blocks — and lifts image handling into the
shared `@lobechat/heterogeneous-agents/spawn/input` module.

Image sources accept URL (with optional id for cache dedupe), local path, or
inline base64. The shared `normalizeImage` fetches/reads/decodes, with
optional on-disk caching keyed by `sha256(id || url)`. `materializeImageToPath`
writes buffers to a cache dir (used by Codex `--image <path>`), with byte-
signature sniffing fallback when MIME is generic. `buildAgentInput` is the
single source of truth for per-agent serialization: Claude Code receives base64
image blocks inline in stream-json; Codex receives text on stdin + repeatable
`--image <path>` flags.

CLI gets three input modes: `--prompt <text>` + `--image <path|url|data:>`
(repeatable), `--input-json <file|->` for full content-block JSON, and stdin
auto-detection (JSON vs plain text by first non-whitespace character).
Mutually-exclusive flag combinations error early.

Desktop's `HeterogeneousAgentCtr` drops ~100 lines of duplicated cache /
sniffing code; helpers (`buildStreamJsonInput`, `resolveCliImagePaths`) become
thin wrappers around the shared functions. Driver interface and IPC contract
are unchanged.

`spawnAgent` is now async (image normalization fetches/reads before spawn).

Verified end-to-end: `lh hetero exec --type claude-code --prompt ... --image
red.png` → CC replied "I see a solid red color." `--input-json` mode also
verified. 28/28 desktop tests, 11/11 CLI hetero tests, 22/22 spawn package
tests pass.

Refs LOBE-8523 (phase 1a follow-up before phase 1b ingest).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔧 chore(cli): include types/model-bank/business-const in workspace

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(types): inline crawler and python-interpreter types

Drop workspace deps on @lobechat/web-crawler and @lobechat/python-interpreter
from @lobechat/types by inlining CrawlSuccessResult / CrawlErrorResult /
CrawlUniformResult and PythonOutput / PythonResult into the relevant tool
type modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔖 chore(cli): bump @lobehub/cli to 0.0.10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(github-tool): prefer description over command in inspector/render header

Show the human-readable `description` arg in the gh tool's collapsed
inspector chip and result-card header when provided; fall back to the
extracted subcommand. Full command is still visible in the expanded
Command code block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): treat generic Content-Type as unknown + handle async spawnAgent failures

Two issues raised on PR #14433 review:

**P1 — generic Content-Type bypassed sniffing in normalizeImage**

`fetchUrlImage` accepted any non-empty `Content-Type` as the final
`mediaType`, so CDN responses defaulting to `application/octet-stream` (or
`text/plain`) skipped URL/byte-based detection and forwarded an unrecognized
type into Claude Code's stream-json `media_type` field — Anthropic rejects
those even when the bytes are a valid PNG/JPEG. The same flaw existed for
base64 sources whose declared `mediaType` was generic.

Introduce `pickImageMediaType(headerType, url, buffer)`: the header value is
preferred only when it's a recognized `image/*` type we know how to extension-
map; otherwise it falls through to URL extension hint → byte-signature sniff
→ raw header → `image/png` final fallback. Applied uniformly to URL fetch,
URL cache hit, and base64 decode paths. Path sources are unchanged (their
"header" is the file extension, which is already authoritative when present).

**P2 — async spawnAgent rejections crashed the CLI**

`spawnAgent` is now async and can reject during image normalization (missing
local `--image` path, fetch failure, decode error). The CLI awaited it
outside any try/catch, so user-input errors surfaced as unhandled rejections
with stack traces instead of the friendly `log.error + process.exit` path
used for prompt validation.

Wrap the `await spawnAgent(...)` in try/catch, log the error message, exit 1
(matching the existing "Stream error from agent process" convention).

**Tests**

- `buildAgentInput.test.ts`: 3 new tests covering octet-stream URL
  Content-Type → byte sniff, octet-stream base64 declared type → byte sniff,
  generic header + URL extension hint preferred over header.
- `hetero.test.ts`: 1 new test verifying spawnAgent rejection produces clean
  `exit(1)` instead of an unhandled rejection.

Manually verified:
  `lh hetero exec --image /tmp/does-not-exist.png`
  → `[ERROR] Failed to start agent: ENOENT: no such file or directory…` + exit 1

Refs LOBE-8523.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:06:52 +08:00
Tsuki 431abf36d6 feat(mobile): add homeRouter to mobile tRPC router (#14438)
Enable mobile app to access home.getSidebarAgentList for migrating
SessionList from sessionId to agentId (LOBE-8401).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 19:12:32 +08:00
AmAzing- ce516fff9d 🐛 fix(space): show document update time (#14366) 2026-05-05 14:32:32 +08:00
Zhijie He 9e231835b2 💄 style: add grok-4.3 for xAI (#14382) 2026-05-05 12:24:43 +08:00
LobeHub Bot 79b84a68ec 🌐 chore: translate non-English comments to English in brief-types and conversation (#14410) 2026-05-05 12:23:26 +08:00
LobeHub Bot 56e811f5bd 🌐 chore: translate non-English comments to English in agentSignal and builtin-tool-claude-code (#14432) 2026-05-05 11:53:02 +08:00
Arvin Xu 5fb795b092 feat(cli): add lh hetero exec for standalone heterogeneous agent runs (#14431)
* 🌐 i18n: add taskDetail.runAll keys for subtask dependency runner

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(cli): add `lh hetero exec` for standalone heterogeneous agent runs (LOBE-8523 phase 1a)

Phase 1a of LOBE-8516: a Node-side `spawnAgent()` plus the CLI command that
drives it. Standalone-only — no `--topic` / `--operation-id` / no server
ingest. Output is `AgentStreamEvent` JSONL on stdout, one event per line.

Why phase 1a is its own milestone: it lets us validate the producer pipeline
end-to-end (`spawn → JsonlStreamProcessor → adapter → toStreamEvent`) under a
plain Node process, get Device-mode + manual debugging unblocked, and ship
without waiting on phase 2's server `heteroIngest` procedures.

## Shared `spawnAgent({ agentType, prompt, resumeSessionId, cwd, command })`

- Lives in `@lobechat/heterogeneous-agents/spawn`. Pure Node — no Electron, no
  image cache, no on-disk tracing, no proxy env composition. Desktop main keeps
  its own bespoke spawn path for those host concerns; this minimal version is
  what the CLI sandbox + terminal use case needs.
- CC: stream-json stdin format + the established preset flags. Codex: `exec` /
  `exec resume` form with `--json --skip-git-repo-check --full-auto`.
- Returns `SpawnAgentHandle` with: async-iterable `events`, `exit` promise,
  `kill(signal)` (Unix process-group kill, Windows direct), `pid`, raw `stderr`.
- Internally a single-queue async iterator coordinates between the stdout
  listeners and the consumer — keeps backpressure simple, no extra deps.

## `lh hetero exec` command

```
lh hetero exec --type claude-code|codex
  [--prompt - | --prompt <text>]   # default stdin
  [--resume <sessionId>]
  [--cwd <path>]                    # default process.cwd()
  [--command <bin>]                 # default `claude` / `codex`
  [--operation-id <id>]             # uuid v4 generated if omitted
```

- Reads prompt from stdin when omitted or `-`.
- Forwards child stderr to ours so users see auth prompts / missing-binary
  errors.
- Ctrl-C → SIGINT to the child's process group (Unix); a second Ctrl-C
  escalates to SIGKILL.
- Exit code passthrough: child code 0/non-0 stays as-is; SIGINT / SIGTERM /
  SIGKILL map to POSIX 130 / 143 / 137.

## Out of scope (phase 1b — next PR)

- `--topic` / `--operation-id` flags as REQUIRED + the BatchIngester
- `--render none|jsonl` flag (phase 1a is implicit JSONL)
- trpc `aiAgent.heteroIngest` / `heteroFinish` calls
- Gateway WS interrupt subscription

## Validation

- `bunx vitest run packages/heterogeneous-agents` — 113 passing (8 new
  spawnAgent tests + the 105 pre-existing on canary)
- `bunx vitest run apps/cli/src/commands/hetero.test.ts` — 7 passing
  (all `--type` / `--prompt` / `--operation-id` / exit-code-passthrough /
  SIGINT-mapping branches)
- Real end-to-end: `bun src/index.ts hetero exec --type claude-code --prompt
  'Reply with exactly the word HELLO and nothing else.'` produced clean
  AgentStreamEvent JSONL (stream_start → 2 stream_chunks → step_complete
  turn_metadata → step_complete result_usage → stream_end → agent_runtime_end),
  every line stamped with the same auto-generated operationId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(spawn): serialize pipeline pushes so flush waits for in-flight chunks

When stdout emits multiple chunks back-to-back — or `'end'` lands while an
earlier `pipeline.push()` is still awaiting the Codex tracker's filesystem
reads — the per-chunk `.then` handlers ran concurrently. Two consequences:

1. Out-of-order events. Push #2's events could resolve before push #1's,
   so the JSONL stream came out shuffled.

2. Late-event loss. `'end'` would call `pipeline.flush()` and immediately
   set `streamEnded = true` while prior pushes were still pending. The
   async iterator could then return `{ done: true }` before those pushes
   queued their events.

Fix: thread every `push()` / `flush()` / error-surface call through a single
`pipelineQueue` `Promise` chain, the same shape the desktop controller uses
for its broadcast queue. `flush()` now reliably runs after every queued
push has drained, so `streamEnded` is the very last write.

Two regression tests cover the failure modes by spying on
`AgentStreamPipeline.push` to inject deterministic delays:

- "preserves event ordering across async pipeline.push() calls" — chunk A
  resolves slower than chunk B; without the chain B arrives first.
- "iterator drains slow in-flight pushes before flushing the stream" —
  `'end'` fires while a 40 ms push is still pending; without the chain
  the iterator returns done before the chunk's events queue.

Bisected: both tests fail without the chain, pass with it. E2E re-smoke
(`bun src/index.ts hetero exec --type claude-code` simple text + tool-using
prompt + stdin) still produces clean ordered JSONL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:41:58 +08:00
Neko fbe71e76db test(workflows,workflows-hono): mixed export of agent signal types for workflow (#14429) 2026-05-05 04:57:52 +08:00
Arvin Xu d83f0a0f2f ♻️ refactor(chat): introduce agentDispatcher.selectRuntimeType (#14428)
* 🔥 refactor: remove dead Search Summary chain

Footer.tsx in web-browsing Search portal had near-zero usage. Removing it
makes the entire chain dead: triggerAIMessage, summaryPluginContent,
fillPluginMessageContent, saveSearchResult, plus the inSearchWorkflow param
threaded through internal_execAgentRuntime.

Part of LOBE-8519 — clears the path before introducing agentDispatcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat: add agentDispatcher.selectRuntimeType

Centralizes the client / gateway / hetero routing decision so every entry
point shares one source of truth. parentRuntime override lets sub-agent
dispatches inherit their parent operation's runtime.

Part of LOBE-8519 — call sites are migrated in following commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: route sendMessage through selectRuntimeType

Compute runtimeType once per sendMessage call and dispatch off it instead of
re-deriving the hetero/gateway/client decision inline. Behavior is identical;
this just centralizes the routing rule (LOBE-8519, A1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: route regenerate / continue through selectRuntimeType

regenerateUserMessage and continueGenerationMessage in the conversation store
now consult selectRuntimeType for routing. Hetero variants of both are not yet
implemented (they currently fall through to client mode with a TODO + warning).

Also drops chatStore.continueGenerationMessage — the conversation-store version
is the only caller; the chat-store duplicate had zero production usage.

Part of LOBE-8519 (A2, B4 deletion, B5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: route resume helpers through selectRuntimeType

approveToolCalling / rejectToolCalling / rejectAndContinueToolCalling now
consult selectRuntimeType (via #shouldUseGatewayResume) using the operation's
own ConversationContext, instead of the bare isGatewayModeEnabled() check.
Behavior is preserved (gateway resume vs. local resume); hetero resume is not
yet implemented and falls through to the client local path.

Part of LOBE-8519 (A3, A4, A5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: route sub-agent dispatch through selectRuntimeType

directMentionRoute and callAgent now consult selectRuntimeType using the
parent agent's config so sub-agent dispatches inherit the parent runtime.
Only the client path is wired today; gateway / hetero variants warn + fall
through with TODOs for follow-up.

Part of LOBE-8519 (B3, B6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor: rename internal_execAgentRuntime to executeClientAgent

Aligns the client runner's name with executeGatewayAgent and
executeHeterogeneousAgent so the three runtimes share a consistent
verb-noun pattern. Pure rename — no behavioral changes; log prefixes
and test mock variables follow the new name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:09:01 +08:00
Arvin Xu fe65741a32 ♻️ refactor(hetero-agent): extract producer pipeline into shared package (#14425)
* 💄 style(todo-progress): use colorFillSecondary so left/right borders are visible against QueueTray

The colorBorderSecondary stroke nearly vanished against the dark elevated bg, so the TODO card looked open on the sides when stacked under QueueTray. Match QueueTray's outer border token (colorFillSecondary) for a consistent visible seam; inner dividers keep colorBorderSecondary as a softer secondary level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(hetero-agent): extract producer pipeline into shared package

LOBE-8516 phase 0. Move the JSONL framing + adapter conversion + toStreamEvent
chain out of the renderer into a new `@lobechat/heterogeneous-agents/spawn`
entry, then have desktop main run it before broadcasting. Renderer now
consumes ready-made `AgentStreamEvent`s on `heteroAgentEvent`, dropping ~50
lines of in-renderer adapter wiring.

This unifies the wire shape across desktop main, the upcoming `lh hetero exec`
CLI, and the server `heteroIngest` handler — every consumer gets the same
stamped `AgentStreamEvent` with no per-consumer adapter step.

The desktop CC flow is unchanged behavior-wise: same adapter, same persistence
ordering, same step-boundary semantics; only the seam between main and
renderer moved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(hetero-agent): pull codex tracker into shared spawn, drop desktop's gateway-client dep

Two cleanups on top of the phase 0 refactor:

1. Move `CodexFileChangeTracker` (+ its test) out of `apps/desktop/src/main/modules/heterogeneousAgent/` into `packages/heterogeneous-agents/src/spawn/`. `AgentStreamPipeline` now auto-instantiates it when `agentType === 'codex'`, so the desktop controller (and the future `lh hetero exec` CLI) stays agent-agnostic — no more "if codex { wire tracker via transformPayload }" branching at the call site. The public `transformPayload` hook is removed since it had no other consumer.

2. Re-export `AgentStreamEvent` / `AgentStreamEventType` from `@lobechat/heterogeneous-agents/spawn` and drop `@lobechat/agent-gateway-client` from `apps/desktop/package.json`. The gateway-client package is a browser-side WebSocket client; producer-side callers (desktop main, sandbox CLI) shouldn't carry it as a direct dep — they only need the type, which now flows through the producer-side entry.

Type predicate on Codex payloads tightened to a non-`Required<>` shape so the moved file passes the root tsconfig's `strict: true` (apps/desktop's tsconfig was lax).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🧑‍💻 chore(local-testing): harden electron-dev.sh process management

Lifecycle improvements for the local-testing helper so smoke runs against the desktop dev session are reliable:

- `find_project_pids` now also catches user-started `bun run dev` Electron sessions (matches by project electron path, not just `--remote-debugging-port`), the launcher subshell saved to PIDFILE, and any process bound to the CDP port. Vite match tightened to `electron-vite[/.].*\bdev\b` so unrelated Vite invocations aren't swept up.
- `do_stop` expands seed PIDs into their descendant trees (DFS via `pgrep -P`), SIGTERMs the whole tree, waits 5s, then SIGKILLs survivors. Belt-and-suspenders sweep for stragglers + anything still bound to the CDP port. Closes the long-standing "Helper processes survive the kill" gotcha.
- `do_start` detects existing project Electron/vite before tearing it down so the user sees what's being killed; waits for port + user-data-dir locks to release before relaunching to avoid the "user data directory in use" race.
- `wait_for_cdp` uses an explicit deadline + early bail-out if the launcher PID dies, instead of the previous fixed-step loop. `wait_for_renderer` no longer pre-sleeps 10s.

`setsid` use is intentional; it puts the launched Electron in its own session so the whole tree shares a PGID we can signal in one shot. Note: `setsid` is GNU coreutils — on macOS without `brew install util-linux` the script will fail at the launch step. Documented as a known limitation; no fallback added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): gate session-complete on stdout fully drained

Node may emit `proc.on('exit')` BEFORE child stdio fully closes (documented
in child_process: "stdio streams might still be open"). Phase 0 of LOBE-8516
moved adapter ownership to main, so renderer no longer flushes its own
adapter on session-complete — meaning trailing events synthesized by
`pipeline.flush()` (e.g. Codex's `tool_end` for unfinished tool calls) would
race against, and lose to, the `heteroAgentSessionComplete` broadcast,
leaving renderer-side persistence to finalize on incomplete state.

Fix: in `proc.on('exit')`, await `streamFinished(stdout)` (covers `'end'`,
`'close'`, and `'error'`) BEFORE awaiting the broadcast queue. The first
await ensures the `stdout.on('end')` handler has had a chance to schedule
`pipeline.flush()` onto the queue; the second drains it. Only then do we
broadcast complete / error.

Regression test repros the documented Node race by emitting `exit` before
`stdout.end()` and asserts every `heteroAgentEvent` (including the
synthesized `tool_end` from `pipeline.flush()`) lands before
`heteroAgentSessionComplete`. Bisected: test fails without the gate, passes
with it.

Also: add `packages/heterogeneous-agents` to `apps/desktop/pnpm-workspace.yaml`
to mirror the new workspace dep added in the phase 0 refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(hetero-agent): drop builtin-tool-claude-code dep, inline the 3 CC wire shapes the adapter needs

Phase 0 added `@lobechat/heterogeneous-agents` as a runtime dep of the desktop
main process. That transitively pulled in `@lobechat/builtin-tool-claude-code`
(declared in the shared package's deps), which the desktop pnpm workspace
doesn't list — CI install on the desktop project fails:

    ERR_PNPM_WORKSPACE_PKG_NOT_FOUND  In ../../packages/heterogeneous-agents:
    "@lobechat/builtin-tool-claude-code@workspace:*" is in the dependencies but
    no package named "@lobechat/builtin-tool-claude-code" is present in the
    workspace

The dep is also a layer-violation: `heterogeneous-agents` is the producer
side (CLI stream → AgentStreamEvent), `builtin-tool-claude-code` is the UI
tool definition (renderers / inspectors / agent template). Producer
shouldn't depend on UI-tool packages, even if today the import is just
types/constants — the dep cascade still drags `shared-tool-ui` etc. into
every workspace that wants the adapter.

Fix: inline the three things the adapter actually uses (`'TodoWrite'` tool
name string, `TodoWriteArgs` interface, `ClaudeCodeTodoItem` interface).
They reflect upstream Claude Code's wire schema — if `claude` ever renames
`TodoWrite`, the adapter and the downstream renderers must both update
regardless of whether they share a constant. Renderer-side packages
(`builtin-tools/codex/TodoListRender`, etc.) keep importing the canonical
`ClaudeCodeApiName` from `@lobechat/builtin-tool-claude-code`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:04:09 +08:00
YuTengjing b5e4cd0805 🐛 fix: revoke sessions after password reset (#14424) 2026-05-05 00:05:05 +08:00
YuTengjing f565ca9450 🐛 fix: revoke sessions after password reset (#14424) 2026-05-04 23:55:48 +08:00
YuTengjing e6d49fdb76 🐛 fix: track visual analysis trigger (#14399) 2026-05-04 23:52:49 +08:00
YuTengjing 47c524a388 🐛 fix: handle Claude assistant prefill errors (#14398) 2026-05-04 23:28:26 +08:00
Arvin Xu cb4412421f ♻️ refactor(local-system,cloud-sandbox): drop "Local" prefix from tool names (#14364)
* ♻️ refactor(local-system,cloud-sandbox): drop "Local" prefix from tool names

LLM-facing tool names dropped the redundant "Local" / "LocalFiles" prefix
to shrink manifest/system-prompt token footprint:
editLocalFile→editFile, globLocalFiles→globFiles, listLocalFiles→listFiles,
moveLocalFiles→moveFiles, readLocalFile→readFile,
searchLocalFiles→searchFiles, writeLocalFile→writeFile.

Also removed `renameLocalFile` entirely from the new surface — `moveFiles`
already covers in-place renames by changing only the filename in newPath.

Old long names are still recognised in the rendering path
(client Render/Inspector/Intervention/Streaming registries, placeholders,
workflow display labels, i18n keys) and in Gateway/CLI routing, so
historical messages and older Gateway versions keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(local-system): reuse LocalSystemApiName / LocalSystemIdentifier exports

Drop the inline LOCAL_SYSTEM_IDENTIFIER / READ_FILE / LIST_FILES consts in
the snapshot materializer and import the canonical values from the package.
Mark LocalSystemApiName `as const` (matching CloudSandboxApiName) so values
narrow to literal types and satisfy LocalSystemToolSnapshot.apiName.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:19:29 +08:00
Arvin Xu 78b3dbed03 feat: devtools gallery rebuild, Review polish, queue-tray images (#14423) 2026-05-04 23:12:59 +08:00
Arvin Xu 95375cec79 ♻️ refactor(builtin-tools): retire lobe-tools alias and slim lobe-notebook to render-only (#14422)
* ♻️ refactor(builtin-tools): retire lobe-tools alias and slim lobe-notebook to render-only

- Drop the deprecated `'lobe-tools'` identifier alias from the inspector / render
  registries plus its backward-compat checks in dbMessage selectors and the dev
  RenderGallery fixtures.
- Hoist the only surviving notebook UI (the `createDocument` document card) into
  `packages/builtin-tools/src/notebook/`, mirroring the github tool layout.
  Marked the new module `@deprecated` with a ~3-month removal target.
- Delete `packages/builtin-tool-notebook/src/client/` entirely and unregister
  notebook from the inspectors / interventions / placeholders / streamings
  registries (it can no longer be invoked by the LLM, so those surfaces are dead
  code). Manifest / executor / ExecutionRuntime stay so legacy tool calls keep
  resolving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔧 chore(builtin-tools): drop redundant antd peer dep

antd is already provided by the workspace and peered through
@lobehub/ui, so listing it explicitly on builtin-tools is noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:58:00 +08:00
Arvin Xu aa3c7e585b 💄 style(builtin-tools): add UI render for github marketplace tool (#14420)
*  feat(builtin-tools): add UI render for github marketplace tool

Register an Inspector + Render for the marketplace `github` MCP tool
(single `run_command` API that wraps the `gh` CLI). Mirrors the codex
pattern under packages/builtin-tools/src/github/.

- Inspector: GitHub brand chip with the parsed gh subcommand and a
  success/error indicator after the call resolves.
- Render: ToolResultCard with the full gh command (sh-highlighted) and
  the output, auto-detected as JSON for `gh api` / `--json` calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(builtin-tools): add inspector renders for moveLocalFiles and exportFile

Cloud-sandbox and local-system both expose moveLocalFiles, and cloud-sandbox additionally
exports exportFile, but none of these had inspector components registered, so the title
area in tool calls fell back to the default loading text. Add a shared
createMoveLocalFilesInspector factory and a cloud-only ExportFileInspector, then wire them
into both packages' inspector registries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(builtin-tools): drop redundant "GitHub:" prefix in github inspector

The chip already shows the GitHub icon and a `gh` prefix next to the subcommand,
so the leading "GitHub:" text was duplicating that signal. Always render the chip
(even when no subcommand has streamed yet) and remove the now-stale margin and
streaming-only branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(builtin-tools): hoist gh prefix out of github inspector chip

Move the literal `gh` text to plain leading copy with the GitHub icon as a separator,
and let the chip carry only the gh subcommand (e.g. `api /repos/...` or `search code ...`).
Reads more like the actual command and lets the verb stand out as the chip's first token.

Also seed a github run_command fixture in /devtools so the chip layout is preview-able.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(builtin-tools): hoist github icon out of chip too

Move the GitHub icon next to the literal `gh` prefix so the chip carries only the
gh subcommand (api /repos/..., search code ..., etc.). Reads as: [icon] gh [chip].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:28:47 +08:00
Arvin Xu 11e6619a3c feat(server,task): batch run subtasks in dependency order (#14418)
*  feat(server,task): batch run subtasks in dependency order

Adds a "Run all" entry on the subtasks panel that kicks off the first
dependency layer; subsequent layers fire automatically as upstream tasks
complete. Layer planning (Kahn topo sort + cycle detection) lives in a
new TaskGraphService and runs server-side via two TRPC procedures.

Also fixes a pre-existing bug where `task.updateStatus(completed)` was
flipping unlocked dependents to `running` without ever invoking the
runner — leaving them in a phantom running state with no topic in
flight. Cascade now goes through TaskRunnerService.cascadeOnCompletion
from all three completion paths (TRPC updateStatus, brief approval,
judge auto-pass), so dependency chains advance end-to-end on their own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(server,task): preserve edges to in-flight and out-of-scope upstreams

The graph used to drop any dependency edge whose upstream wasn't in the
runnable set. That silently freed two correctness-breaking cases:

- A backlog subtask that depends on a *running / scheduled* sibling
  landed in layer 1 and got kicked off before its blocker finished.
- A descendant that depends on a task *outside the current subtree*
  (allowed by the schema) lost its blocker entirely and ran prematurely.

Edges are now classified per dependency: terminal-OK upstreams drop the
edge; in-batch runnable upstreams keep their in-degree contribution; any
other status — in-flight, runnable but out of scope, or unknown — marks
the dependent as `blockedExternally` and excludes it from the layered
plan. External blockage propagates transitively through in-batch edges
so we never run a downstream of a blocked task either. `planForParent`
fetches statuses for cross-scope upstreams so the classifier has real
data to decide on.

The UI surfaces the new bucket via `RunSubtasksPreview` and keeps the
modal open (with the run button disabled) when a plan has nothing to
start but does have blocked tasks worth explaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:44:40 +08:00
Arvin Xu 41719dfd29 🐛 fix(gateway): unstick input loading on auth_failed + recoverable auth_expired (#14419)
* 🐛 fix(gateway): complete local op on auth_failed to unstick input loading

When the gateway client receives `auth_failed` (server has GC'd the op or
the refreshed JWT no longer matches), the local op stayed `running`
forever — input kept the stop button, and `topic.metadata.runningOperation`
never cleared, so every revisit re-fired the same broken reconnect.

Treat `auth_failed` as session-terminal alongside `session_complete` so
`onSessionComplete` fires and `completeOperation` runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(gateway): support recoverable auth_expired with token refresh

When the JWT expires while the operation is still alive on the server,
sending `auth_failed` is wrong — the op is fine, only the credential
went stale. Treat that as a separate, recoverable signal instead.

Server (agent-gateway repo) emits a new `auth_expired` message and
keeps the WebSocket open. The client refreshes its JWT (via the
existing `aiAgentService.refreshGatewayToken`), updates the in-flight
client, and reconnects. `auth_failed` stays terminal for cases where
the op truly no longer exists.

Mirrors the device-gateway-client pattern (`auth_expired` event +
`updateToken` + `reconnect`). If no `tokenRefresher` is wired in (or
the refresh itself fails), we fall back to terminal so the input
doesn't stay stuck on the loading state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(gateway): disconnect ws on auth_expired without tokenRefresher

The server keeps the WebSocket open after `auth_expired` (so the client
can refresh and re-auth on the same connection). When no `tokenRefresher`
is wired in, we mark the local op complete but were leaving the socket —
heartbeat and autoReconnect kept running indefinitely after the op was
gone, leaking background connections.

Mirror the refresh-failure branch and call `client.disconnect()` before
firing onSessionComplete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(gateway): make tokenRefresher required on connectToGateway

Both real callers (executeGatewayAgent + reconnectToGatewayOperation)
already supply a refresher built from `aiAgentService.refreshGatewayToken`,
and there's no scenario where a Gateway op runs without a topic to refresh
against. The optional path was carrying its own foot-gun (socket leak if
forgotten) and a defensive ternary on `result.topicId` that the type
already rules out.

Required-only collapses both into the existing refresh-failure branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(gateway): inline token refresh, take topicId instead of refresher

Both callers of connectToGateway built identical refresher closures over
`aiAgentService.refreshGatewayToken(topicId)`. Pass `topicId` directly and
let connectToGateway call the service inline — gateway.ts already imports
aiAgentService for the cancel-handler path, so no new coupling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 chore(gateway): rewrite stale auth_expired comment

The "no refresher provided" branch is gone — fold that case out of the
comment and explain why the catch branch needs explicit disconnect().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:39:31 +08:00
Arvin Xu b66e83a57c 🐛 fix(security): add max pagination limits to tRPC endpoints (#14415)
* fix(security): add max(50) pagination cap to file.recentFiles and file.recentPages

Fixes GHSA-jr3g-w7rp-fhm9: unbounded limit parameter in recentFiles
and recentPages endpoints allowed authenticated users to trigger
arbitrarily large DB queries (amplified 3x before the DB call).

Adds .max(50) Zod constraint to cap both endpoints at 50 items.

* fix(security): add pagination caps to topic.getTopics, rankTopics, recentTopics

Fixes GHSA-jr3g-w7rp-fhm9:
- getTopics.pageSize: .max(100)
- rankTopics input: .max(50)
- recentTopics.limit: .max(50)

* fix(security): add pagination caps to session.getSessions and rankSessions

Fixes GHSA-jr3g-w7rp-fhm9:
- getSessions.pageSize: .max(100)
- rankSessions input: .max(50) (multi-JOIN aggregate query)

* fix(security): add max(100) pagination cap to agent.queryAgents

Fixes GHSA-jr3g-w7rp-fhm9: unbounded limit parameter in queryAgents
allowed resource exhaustion via arbitrarily large DB queries.

* fix(security): add max(100) pagination cap to document.queryDocuments

Fixes GHSA-jr3g-w7rp-fhm9: unbounded pageSize parameter in queryDocuments
allowed resource exhaustion via arbitrarily large DB queries.

* 🐛 test(web-crawler): remove zhihu test cases after rule removal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:53:28 +08:00
Arvin Xu bc103b2e11 ♻️ refactor(web-crawler): remove zhihu-specific crawl rules (#14414) 2026-05-04 14:54:46 +08:00
AmAzing- d28b401aaf refactor: agent list reuse to isolate drawer state (#14411)
 Refactor agent list reuse to isolate drawer state
2026-05-04 12:01:06 +08:00
Neko a79cdd19f8 ️ perf(server,agent-signal): improved skill intent detection (#14409) 2026-05-04 06:33:58 +08:00
Neko 222f525bf4 ♻️ refactor(types,agent-signal): request trigger will use agent-signal enum (#14408) 2026-05-04 04:56:47 +08:00
Neko 317fdcec13 feat(app,agent-signal): new agent recent activities to display for signal receipts triggered (#14407) 2026-05-04 04:14:54 +08:00
Neko 162d6cfa67 🐛 fix(userMemories): should parse and validate date string for time intent (#14406) 2026-05-04 04:14:13 +08:00
Arvin Xu 2870cc73c2 feat(builtin-tool-task): add Inspector + Render, batch createTasks/runTasks (#14403)
*  feat(builtin-tool-task): add Inspector + Render, batch createTasks/runTasks

Adds chip-style Inspector and per-API Render to the lobe-task tool, plus two
batch APIs (createTasks, runTasks) so an agent can plan or launch a set of
subtasks in a single call instead of calling createTask/runTask N times.

runTask/runTasks call taskService.run, actually triggering TaskRunnerService
and producing a topic+operationId — distinct from updateTaskStatus(running),
which only flips a flag. The system prompt now spells this out so the model
stops conflating the two. Already-running, missing-assignee, and per-item
failures surface back to the agent with clear messages.

Fixes LOBE-8438

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(server,task): implement createTasks/runTask/runTasks in server runtime

The manifest exposes these APIs to the model, but only the client-side
executor was implemented. Server-side tool execution (src/server/services/
toolExecution/builtin.ts) throws "Builtin tool ... is not implemented" when
the runtime is missing a method, so production paths that route through the
server runtime would fail at runtime.

- Extracted createTaskImpl as a reusable closure so createTasks loops can
  reuse the parent-resolution + assignee-validation flow without copy-paste
- runTask / runTasks call taskCaller.run(...) which already routes to
  TaskRunnerService — same execution path as the UI/CLI run buttons
- runTasks continues past per-item failures and reports them in the summary
  (matching the client executor's behavior)
- Added 7 tests (20 total in this file) covering happy path, per-item
  failure, missing identifier, and current-task fallback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task-drawer): hide topic feedback input until run terminates

Feedback can only steer the next run, so showing the input while the
topic is pending/running was misleading — gate it on terminal status
(completed/failed/canceled/timeout).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 01:15:41 +08:00
Arvin Xu d5097c7964 💄 fix(builtin-tool-agent-documents): wire Inspectors into registry, switch to chip UI (#14404)
* 💄 fix(builtin-tool-agent-documents): wire Inspectors into registry, switch to chip UI

The Inspector components for lobe-agent-documents existed but were never
registered in packages/builtin-tools/src/inspectors.ts, so the chat UI fell
back to the default "(id:316c6ad5-10e7-46ff-8ccf-15f2359c19...)" header
that shows raw param dumps. Registering them is the root fix.

While in there, refactored all 9 inspectors to the chip pattern used by the
other builtin tools — full UUIDs are noisy in a one-line header, so document
ids are truncated to their first 8 chars (prefixed ids like agd_… are left
intact since they're already short). Each inspector now surfaces the most
useful per-API context: title chip when known (Read/Create), id chip + new
title (Rename/Copy), op count + success ratio (Modify), char count
(Replace), target scope + doc count (List), rule type (UpdateLoadRule),
red dashed line-through (Remove). Shared chip styles live in one
_styles.ts so the visual language stays consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 📝 docs(.agents/skills): add builtin-tool skill

Self-contained reference for building/extending lobe-* builtin tools —
SKILL.md entry point plus architecture / tool-design / ui deep-dives.
Sits alongside the other agent skills.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 00:56:06 +08:00
Neko aa3d245cfd ♻️ refactor(server,prompts,builtin-tool-skill-maintainer): correct context passing, skill format, chained (#14397) 2026-05-03 23:30:44 +08:00
Arvin Xu 61c3f42f10 🐛 fix: sync DEFAULT_MODEL into desktop business-const stub (#14402)
🐛 fix: sync DEFAULT_MODEL/DEFAULT_MINI_MODEL into desktop business-const stub

#14379 moved DEFAULT_MODEL and DEFAULT_MINI_MODEL into @lobechat/business-const,
but the desktop workspace stub at apps/desktop/stubs/business-const wasn't
updated, breaking the desktop client build with MISSING_EXPORT errors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:29:04 +08:00
YuTengjing 2dd52c6813 feat: show original pricing and prioritize DeepSeek (#14391) 2026-05-03 01:27:52 +08:00
Arvin Xu 3f82249ed1 💄 style: add feedback input at bottom of TopicChatDrawer (#14392)
*  feat: add feedback input at bottom of TopicChatDrawer (LOBE-8441)

Mount a comment box inside the Topic Run drawer so users can leave
feedback and trigger a follow-up topic run without leaving the drawer.
Send button calls addComment then runTask (without continueTopicId, so
a brand-new topic is started instead of resurrecting the completed one).

Existing AgentTaskDetail/CommentInput is untouched — the new component
lives next to TopicChatDrawer and stays separate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix: close TopicChatDrawer after submitting feedback

Closing the drawer once the comment is persisted and the new run is
kicked off matches user expectation — leaving it open made it look
like the existing topic was the one being run again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:07:43 +08:00
LobeHub Bot b49c1c15b7 🤖 style: update i18n (#14383)
💄 style: update i18n

Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2026-05-03 01:07:03 +08:00
YuTengjing df32dd4966 feat: support model defaults and DeepSeek pricing (#14379) 2026-05-02 23:21:09 +08:00
YuTengjing b5d7696dbd feat: add visual understanding tool (#14378) 2026-05-02 22:18:50 +08:00
Arvin Xu d2d81ba64a 💄 style(document-modal): show skeleton for title while loading (#14377)
* 💄 style(document-modal): show skeleton for title while document is loading

Replace the "Untitled" placeholder and AutoSaveHint with a skeleton in both the modal header and the in-page title editor while the document is still being fetched, so the empty fallback no longer flashes before content arrives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task-detail): add run-now dropdown next to cancel-schedule button

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task-artifacts): show created time and sort newest first

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:51:09 +08:00
YuTengjing b2130f7612 🐛 fix: handle auth captcha retries (#14346) 2026-05-01 18:27:04 +08:00
Arvin Xu 626d274859 🔨 chore(release-template): clean up changelog templates (#14375)
* 🔨 chore(release-template): drop Highlights from db-migration changelog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔨 chore(release-template): drop version numbers from changelog templates

Patch releases auto-bump on merge, so the version isn't known when the
changelog is authored. Replace `# 🚀 LobeHub v<x.y.z> (YYYYMMDD)` with
`# 🚀 LobeHub Release (YYYYMMDD)` in all changelog examples and the
GitHub Release Changelog Template inside SKILL.md, and replace the
hard-coded `Since v...` / `Full Changelog: v...v...` lines in the
weekly-release example with the same `<previous-tag>` placeholder
already used by the SKILL.md template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:46:16 +08:00
Arvin Xu 9c509680b9 🚀 release: sync main branch to canary (#14374)
Automatic sync from main to canary. Merge conflicts detected.

**Resolution steps:**
```bash
git fetch origin
git checkout sync/main-to-canary-20260501-25207007930
git merge origin/main
# Resolve conflicts
git add -A && git commit
git push
```

> Do NOT merge canary into a main-based branch — always merge main INTO
the canary-based branch to keep a clean commit graph.
2026-05-01 16:33:03 +08:00
Arvin Xu 70f81ad1a1 🚑 fix: resolve unresolved merge conflict markers in main→canary sync
Keep canary-side logic in useSend (active home agent), feedback action
planner procedure-state, useSend test mocks, and e2e Home chat-input
step. The main-side blocks referenced removed symbols and outdated
action-planning code that would break compile/tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:18:01 +08:00
Arvin Xu c401d1b97f Merge remote-tracking branch 'origin/main' into sync/main-to-canary-20260501-25207007930 2026-05-01 15:57:49 +08:00
lobehubbot eddb0c991b 🔖 chore(release): release version v2.1.56 [skip ci] 2026-05-01 07:49:26 +00:00
lobehubbot 6340ab55e9 chore: merge main into canary (has conflicts to resolve) 2026-05-01 07:47:44 +00:00
Neko 86a23b5555 👷 build(database): add metadata and trigger to briefs table (#14354)
* 👷 build(database): add metadata and trigger to briefs table

*  test(server): should not use adhoc Date.now() (#14280)
2026-05-01 15:47:02 +08:00
Arvin Xu 3cb06e07e3 💄 style(taskDetail): force daily briefs for scheduled tasks; switch activity timestamps to absolute date (#14367)
*  feat(brief): always synthesize a brief on scheduled-task ticks

Heartbeat ticks remain mid-loop nudges and are still skipped, but
schedule-mode tasks now bypass both the trivial-content rule gate and
the LLM emit-vote so each scheduled run produces a daily brief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(taskDetail): switch activity timestamps to absolute date once gap exceeds one day

Adds formatActivityTime helper to @lobechat/utils/time: relative phrasing
under 24h, localized date (e.g. "4月29日" / "Apr 29") afterwards, with the
full datetime exposed via the native title attribute on hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(brief): fork chainGenerateBrief prompt so scheduled ticks always produce a brief

The default prompt instructs the LLM to pair `emit=false` with an empty
title, so even after we bypassed the emit-vote for scheduled tasks the
downstream `!title || !summary` guard could still drop the brief and
silently break the "every schedule tick must produce a brief" contract.

chainGenerateBrief now takes a forceEmit flag; when true it swaps to a
scheduled-tick prompt that removes the skip branch and mandates a
non-empty title/summary, including the "no new activity today" path.
synthesizeTopicBrief passes forceEmit=true for schedule-mode tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update @google/genai version to ~1.50.1

* 💄 style(conversation): stack TodoProgress + QueueTray as a floating overlay above ChatInput

Move TodoProgress out of normal flow and render it together with QueueTray
inside ChatInput as a single absolute-positioned overlay anchored to the
input's top edge. The overlay no longer pushes ChatList up; instead it sits
as a "cover layer" above the scroll viewport.

To keep chat content reachable above the overlay, expose the overlay's
measured height via the conversation input store (ResizeObserver in
ChatInput) and have VList consume it as `paddingBottom = max(24, height +
12)` — the +12 compensates for ChatInput's `marginTop: -12`. BackBottom
also reads the same height via a new `bottomOffset` prop so the
back-to-bottom button lifts above the overlay instead of being occluded.

QueueTray sits on top, TodoProgress below; TodoProgress squares its top
corners (`topAttached`) when QueueTray is present so the two panels fuse
into a clean stack with no notches at the seams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(utils): make formatActivityTime title assertion timezone-independent

The test hardcoded `2026-05-01 13:00:00` (UTC+8 author tz), so it failed in
UTC CI as `2026-05-01 05:00:00`. Derive the expected title via the same
dayjs format the implementation uses so the assertion holds regardless of
the runner's timezone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(conversation): include chatInputOverlayHeight in store mock state

The store always initializes chatInputOverlayHeight to 0 via inputInitialState,
so the State type rightly keeps it required. The selectors test mock simply
missed the field after the slice gained it; supply 0 to match the real
initial state instead of weakening the type to optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(brief): split judge from generate, persist decision on task topic

Split the brief-emission flow into two independent stages so judgment and
copy-generation are no longer entangled in a single LLM call (which made
the scheduled-tick fork necessary in the first place).

- Rule layer (`shouldEmitTopicBrief`) goes three-state: `'yes' | 'no' |
  'unknown'`. Conclusive cases (error / review-handled / review-configured
  / heartbeat / trivial-non-scheduled / scheduled) bypass the LLM entirely;
  only manual + non-trivial topics fall through to `'unknown'`.
- New `chainJudgeBriefEmit` (small chain, returns `{emit, reason}`) is
  invoked ONLY on the `'unknown'` branch. Title/summary copy is no longer
  in scope for this call.
- `chainGenerateBrief` drops the `forceEmit` fork and the `emit` field —
  it now assumes the caller has already decided to emit and just produces
  `{title, summary}`. Saves tokens on skip paths since we never draft copy
  for a brief that won't be persisted.
- Every decision (rule or LLM) is persisted to
  `taskTopics.handoff.briefDecision` via a new `updateBriefDecision` model
  method using `jsonb_set + COALESCE` so existing handoff fields aren't
  disturbed. Gives operators a per-topic audit trail of why a brief was
  or wasn't produced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(brief): emit on errors, defer heartbeat to LLM judge

Two follow-up tweaks to the rule layer (`shouldEmitTopicBrief`):

- `reason === 'error'` is no longer a hard skip — the user must be told the
  run failed. Returns `{emit: 'yes', reason: 'execution-error'}` so once
  the error path is folded into `synthesizeTopicBrief` (separate
  consolidation refactor) the verdict is correct without further changes.
  Currently dead code: `onTopicComplete` still builds an urgent error
  brief inline at the `else if (reason === 'error')` branch.
- Heartbeat ticks change from a hard `'no'` to `'unknown'`. Most ticks are
  mid-loop noise but the occasional one warrants surfacing, and only the
  LLM can read the content to tell. Heartbeat is at minimum 10 min so the
  added judge call per tick is acceptable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:37:15 +08:00
Innei c9b44935ed revert: revert pnpm v11 migration (#14372)
* Revert "👷 build: disable pnpm gvs for desktop ci (#14357)"

This reverts commit 948ba5ec68.

* Revert "👷 build(repo): migrate to pnpm v11 and consolidate workspace config (#14316)"

This reverts commit 1d9b6099bd.
2026-05-01 14:45:28 +08:00
Innei 948ba5ec68 👷 build: disable pnpm gvs for desktop ci (#14357)
* 👷 build: disable pnpm gvs for desktop ci

* 👷 build: increase desktop install heap

* 👷 build: raise linux desktop file limit

* 👷 build: skip desktop package rebuild

* 👷 build: hoist desktop isolated install

* 👷 build: skip desktop dependency collector

* 👷 build: mark desktop modules externally handled

* 👷 build: limit desktop native runtime deps

* 👷 build: include get-windows runtime resolver deps
2026-05-01 13:17:21 +08:00
LiJian d0091901dc 🐛 fix(skill): skip OAuth redirectUri on desktop to prevent broken app (#14345)
🐛 fix(skill): skip OAuth redirectUri on desktop to prevent broken app:// navigation

On desktop (Electron), window.location.origin is app://renderer which the system browser cannot navigate to. Skip passing redirectUri so market shows a default success page instead, relying on existing window-close monitoring and fallback polling to detect OAuth completion.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 13:00:55 +08:00
Arvin Xu 8c3b83f8b3 🐛 fix(local-system): tokenize mdfind keywords, scope glob to home, align tool prompts (#14358)
* 🐛 fix(local-system): tokenize mdfind keywords, scope glob to home, align tool prompts

- mdfind treats free-form keywords as a single literal substring; "LobeHub
  Financial Statement" never matches "Financial_Statement_LobeHub.pdf".
  Split on whitespace and AND each token (still substring-matched) so
  ordering doesn't matter.
- Unix/Windows glob fell back to process.cwd() — meaningless inside a
  packaged Electron app. Default to os.homedir() instead so unscoped
  patterns can actually find user files.
- systemRole/systemRole.desktop documented `query`/`onlyIn`/`path` for
  searchLocalFiles/grepContent/globLocalFiles, but the manifest exposes
  `keywords`/`scope`. The wrong names were silently dropped, so the LLM
  could never scope its searches. Aligned the prompts with manifest and
  noted the new keyword-tokenization semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(local-system): preserve glob/grep error in tool message content + tidy file row UI

Two independent bugs that combined to break Glob/Grep tool messages and
then made search hits look ugly in the result list.

Empty `content` on glob failure
- LocalSystemExecutionRuntime.normalizeResult dropped `raw.error` when
  mapping `globLocalFiles`/`grepContent`, so a failure from the IPC layer
  (e.g. fast-glob throwing EACCES while traversing the wrong cwd) became
  `{ result: {...}, success: false }` with no error attached.
- ComputerRuntime.errorOutput then did
  `result.error?.message || JSON.stringify(result.error)`. With error
  undefined that yields the value `undefined` (not the string), which
  collapsed into `content: ""` downstream — the chat store still saved
  `pluginState` so users saw a tool message with state set but the
  Response panel completely blank.
- Propagate `raw.error` through normalizeResult and harden errorOutput
  with a "Tool execution failed" fallback so the LLM and the debug panel
  always get a real string.

Search results layout
- FileItem stacked filename and a redundant full path on a single
  baseline-aligned row, so the path column repeated the filename and
  felt visually off-balance.
- Switch to a two-line layout: filename on top, parent directory only
  (collapsed via displayRelativePath when available) underneath, both
  vertically centered against the file icon.
- Promote the "open containing folder" action from hover-only to a
  permanent right-side button so it's reachable in one click.
- Bump the SearchFiles scroll container so the taller rows still show a
  reasonable number of hits before scrolling, and add a Downloads-style
  fixture to the dev panel render gallery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(local-system): harden executor toResult to never emit empty content and to keep state on failure

The earlier fix patched normalizeResult and ComputerRuntime.errorOutput,
but the central funnel where every executor return is shaped —
LocalSystemExecutor.toResult — still trusted the runtime output blindly:

- the success=false branch dropped `state` entirely, which meant any
  partial pluginState a runtime had built up was thrown away the moment
  it reported an error (renderers then re-rendered as if the call had
  produced nothing).
- both branches passed `output.content` through verbatim, so an
  upstream regression that forgot to populate content (the recent Glob
  EACCES path) would still surface as a blank Response panel.

Make toResult the strict gate it claims to be: derive a non-empty
content from `output.content -> output.error.message -> "Tool execution
failed"`, and always propagate `state` regardless of `success`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔒 chore(devtools): sanitize searchLocalFiles fixture to use synthetic data

Replace real-looking filenames, paths and corporate identifiers in the
RenderGallery fixture with neutral sample-user / sample-quarterly-report
placeholders. The fixture is checked into the repo and shipped to every
contributor's dev panel — it shouldn't carry data that resembles a
specific person's Downloads/iMessage/WeChat layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:19:31 +08:00
Arvin Xu b031513321 🐛 fix(brief): keep recurring tasks active when resolving their result briefs (#14348)
* 🐛 fix(brief): keep recurring tasks active when resolving their result briefs

Approving a `result` brief on a recurring (`automationMode='schedule'`)
task was flipping the parent task to `completed`, which removed it from
the active board and stopped future scheduled runs from surfacing on it.
A daily brief is one occurrence — accepting it is a UI dismissal, not a
lifecycle terminal.

The discriminator is the **task's** automation mode, not the brief's
`cronJobId`. A manual run of a recurring task has `cronJobId=null` but
the task is still recurring, so a cronJobId-based check would let that
case slip through.

- Server: `BriefService.resolve` now loads the task and only completes
  it when `automationMode !== 'schedule'`.
- Server: `enrichBriefsWithAgents` also batches the task lookup and
  exposes `taskAutomationMode` on the listed briefs so the UI can label
  the action correctly without an extra round-trip.
- UI: the result action label switches to "Mark as resolved" /
  "标记为已解决" when `taskAutomationMode === 'schedule'`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief): unify result brief action to "Confirm" and key off task status

Replace the dual confirmDone/markResolved labels with a single brief.action.confirm,
and gate task completion on task.status !== 'scheduled' so heartbeat-mode tasks
parked between ticks are also kept active when one of their result briefs is
approved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief): restore "Confirm complete" for terminal-accept; "Confirm" only for status='scheduled'

Bring back brief.action.confirmDone alongside the new brief.action.confirm.
The dual-label discriminator is the parent task's runtime status: tasks parked
at 'scheduled' show "Confirm" (dismiss-only — server keeps them active for the
next tick), all other states show "Confirm complete" since approving will flip
the task to completed. Server keeps its task.status !== 'scheduled' guard.

Threads taskStatus on BriefItem / BriefWithAgents (replacing the previously
removed taskAutomationMode) so the UI label matches the actual server effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(brief): make BriefItem.taskStatus optional for locally-constructed briefs

TaskActivities.tsx builds a BriefItem from a TaskDetailActivity row and has no
task-status info to pass through. Marking the field optional matches the prop
shape on BriefCardActions and lets the activity feed compile again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:26:03 +08:00
Innei c2b379139d feat(followUpAction): add quick-reply chips below assistant messages (#14350)
*  feat(followUpAction): add shared types and JSON schema for follow-up chip extraction

* 🐛 fix(followUpAction): tighten JSON schema literal types with top-level as const

*  feat(followUpAction): add base + onboarding prompt builders

*  feat(followUpAction): add server service to extract chips via fast LLM

* 🐛 fix(followUpAction): drop empty chips and consolidate schemas in schema.ts

*  feat(followUpAction): expose extract via lambda TRPC router

*  feat(followUpAction): add client service wrapper around TRPC mutation

*  feat(followUpAction): add zustand store with abort/timeout actions

* 🐛 fix(followUpAction): stabilize empty selector ref and abort on reset

*  feat(followUpAction): add FollowUpChips component with reply icon style

*  feat(followUpAction): add onboarding glue hook with phase/greeting guards

*  feat(followUpAction): wire chips + glue hook into onboarding conversation

* 🐛 fix(followUpAction): drop unused eslint-disable directive in client service

* 🐛 fix(followUpAction): tighten types and align prompt with schema bounds

* 🐛 fix(followUpAction): use fresh phase for chip extraction across phase boundaries

* 🐛 fix(followUpAction): type SUGGESTION_RESPONSE_JSON_SCHEMA against GenerateObjectSchema

The earlier `as const` widened to readonly literal types, which is incompatible
with the mutable `GenerateObjectSchema` interface required by `generateObject`.
Replace with an explicit type annotation so the literal is checked at definition
and stays assignable at the call site.

* ️ perf(followUpAction): only refresh user/agent caches at onboarding phase boundaries

The previous logic refreshed both useUserStore and the webOnboarding builtin
agent after every assistant turn, but their content only changes when the
phase advances or onboarding finishes. Compare prev vs next phase/finishedAt
from syncOnboardingContext and skip the two refresh calls when neither moved,
saving an RPC per intra-phase turn.

* 🐛 fix(followUpAction): read finishedAt from agentOnboarding subobject

* ♻️ refactor(followUpAction): take agentId from caller and resolve model from agent config

Drops the env-var override path on the server. The service is meant to be
generic across consumers, so the caller now passes the agentId of the
conversation context. The service resolves model/provider from
AgentModel.getAgentConfigById, falling back to DEFAULT_SYSTEM_AGENT_CONFIG.topic
when the agent has no explicit model. The onboarding caller passes the
webOnboarding builtin agent id; future consumers pass theirs.

* 🐛 fix(followUpAction): resolve latest text assistant message server-side via topicId

*  feat(followUpAction): mirror assistant language and ban deferral chips

Two prompt rule changes:

1. Match the assistant message's language instead of forcing English. The
   chip should be in the script the user would naturally reply in.
2. Prefer questions with explicit options when the message contains
   several, and ban "Let me think / Skip / You decide / Let me explain"
   style escape-hatch chips entirely. Every chip must be a concrete
   reply the user might actually send; the user can always type
   freely, so meta deferral chips just waste a slot.

* 🐛 fix(followUpAction): bump timeout to 20s and silence TRPC-wrapped abort

The previous 3s timeout aborted the LLM call before generateObject could
respond — a typical extract round-trip is ~10s. Bump to 20s.

Also silence the TRPCClientError that wraps the abort: TRPC re-throws
DOMException as TRPCClientError("signal is aborted ..."), so the
original `instanceof DOMException` check missed it and noise
`[FollowUpAction] extract failed` warnings hit the console on every
manual clear / new turn. Now we also short-circuit on `signal.aborted`.

* feat: enhance chat input functionality with new flags

- Added `disableMention` and `disableSlash` props to `ChatInput` and `StoreUpdater` to control mention and slash command triggers.
- Introduced `disableFollowUpVariant` and `disableQueue` props to manage placeholder behavior and message queuing during agent streaming.
- Updated `FollowUpChips` to handle topic IDs and prevent rendering during message generation.
- Refactored onboarding context retrieval to streamline fetching of user persona and state.
- Removed deprecated onboarding state API references and adjusted related tests.
- Improved follow-up action handling to discard stale results based on active request controllers.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: enhance agent marketplace onboarding with summaries and improved state management

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-01 01:20:45 +08:00
Arvin Xu 6d1d8a0d16 💄 style(brief): use Footprints icon and hide view-run until card hover (#14347)
* 💄 style(brief): use Footprints icon and hide view-run until card hover

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief): swap icon to Workflow for the View run shortcut

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:34:01 +08:00
Arvin Xu dc3c48e469 🐛 fix(local-system): forward all search params and guard empty mdfind (#14355)
* 🐛 fix(local-system): forward all search params and guard empty mdfind

- Pass through all resolved searchFiles params (keywords, fileTypes,
  date range, scope, etc.) instead of dropping everything except
  `directory`, which previously caused the executor to call mdfind
  with no query.
- Surface missing fields (`keywords`, `fileTypes`, `contentContains`,
  date range, sort, etc.) on `SearchFilesParams` so the cross-runtime
  type matches the actual contract.
- Short-circuit Spotlight search when there is no query expression so
  mdfind doesn't print its usage text and get parsed as phantom file
  hits, and drop unstattable rows instead of fabricating 0-byte
  placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(skills): guard empty command and forward description in desktop execScript

Desktop skills' execScript dropped `description` before IPC, so when an LLM tool call arrived without `command` (aborted stream, empty args, etc.) the runner crashed on `command.slice(0, 50)` and surfaced as "Failed to execute command: ...".

- runner.ts: return a proper error result when `command` is missing instead of throwing
- lobe-skills.desktop.ts: forward `options.description` to localFileService.runCommand for better logs and as a fallback when command is absent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(local-system): show empty state when file search returns no results

Previously the SearchFiles result panel rendered an empty Flexbox when there were 0 hits, leaving the area visually blank below "Number of searches: 0". Reuse the same Block + Empty pattern as web-browsing search and the existing `search.emptyResult` i18n key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(local-file-shell): expand leading ~ in file operation paths

Node fs APIs don't expand `~` like a shell would, so paths supplied by
the LLM or pasted by users were failing with ENOENT. Apply expandTilde
across read/write/edit/move/rename/list/glob/grep/search and the desktop
search controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(local-system): show empty state when listed directory has no files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:22:14 +08:00
AmAzing- 79dc61ac50 🐛 fix: subtask assignee refresh and rollback handling (#14353)
* Handle subtask assignee rollback refresh

* Ignore cache refresh failures after successful task update
2026-04-30 22:45:11 +08:00
AmAzing- 506bb7b29f Fix task subtitle and assignee trigger layout (#14351) 2026-04-30 19:05:51 +08:00
Innei 807af0688f 🐛 fix: type tag cloud pointer event (#14352) 2026-04-30 19:00:54 +08:00
Innei 1d9b6099bd 👷 build(repo): migrate to pnpm v11 and consolidate workspace config (#14316)
* 👷 build(repo): migrate to pnpm v11 and consolidate workspace config

Made-with: Cursor

* 👷 fix pnpm v11 install config
2026-04-30 17:56:22 +08:00
LiJian 5fc7eea754 🐛 fix: inject skill instruction into tool system role (#14342)
*  feat: inject skill instruction into tool system role

Consume the `instruction` field from market SDK's `listTools` response
and pass it as `systemRole` on the tool manifest, so the LLM receives
skill-level guidance documentation via `<tool.instructions>` in the
system prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: update market-sdk

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 17:06:28 +08:00
YuTengjing a9716975a7 💄 style: unify notification setting item copy (#14343) 2026-04-30 16:56:45 +08:00
Arvin Xu c77d201c49 💄 style(brief): open run topic drawer from daily brief card (#14340)
*  feat(brief): open run topic drawer from daily brief card

Adds a "View run" shortcut to the brief card's actions row that opens
the corresponding topic chat drawer in place on the home page, so the
user can inspect the agent's actual run without navigating to the task
detail page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🌐 i18n(brief): refine zh-CN copy for view run action

"查看执行" was ambiguous (could read as "execute"); use "查看运行轨迹"
to make it clear the action opens the agent's actual run trace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:47:24 +08:00
Arvin Xu 39107ba107 ️ perf(agent,working-sidebar): cut Review tab open latency ~9× on large dirty trees (#14338)
* ️ perf(agent,working-sidebar): cut Review tab open latency ~9× on large dirty trees

Two changes that together drop "open Review tab" from ~1.7s to ~190ms on a
working tree with 200+ dirty files:

- GitCtr.getGitWorkingTreePatches: replace N-parallel `git diff` subprocesses
  with one bulk `git diff HEAD --` for tracked files (split per-file in JS) and
  direct `fs.readFile` synthesis for untracked. Eliminates the main-process
  fork storm and `.git/index` lock contention. IPC drops 635ms → ~160ms.
- Review/index.tsx: replace default-expand-all with a size budget
  (≤100KB cumulative patch OR 50 files). Caps Shiki tokenizer cost on first
  paint and removes the 1064ms renderer freeze; small-diff workflows still
  get 50 panels open, big-refactor workflows clamp to 2–3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent,working-sidebar): handle special-char paths and bulk diff overflow

Address two P2 review issues on the perf refactor (#14338):

- Quote untracked paths in synthetic diff headers. Direct interpolation of
  entry.filePath into `diff --git` / `+++` lines emitted malformed headers
  for filenames containing TAB / LF / CR / quote / backslash, causing the
  patch parser to choke (e.g. TAB-containing names triggered "bad git-diff -
  inconsistent new filename"). New quoteGitPath mirrors git's own
  quote_c_style: prefix lives inside the quotes, control bytes get octal
  escapes. Plain ASCII spaces stay unquoted to match git's output.

- Replace fixed-buffer bulk diff with streamed spawn + per-file fallback.
  The 64 MB execFile maxBuffer would reject the entire bulk diff on
  overflow, leaving every tracked file as an empty placeholder. Now bulk
  output streams via spawn (no ceiling), salvages partialStdout on failure,
  and routes any uncovered tracked entry through fetchTrackedPatchPerFile
  with concurrency 8 — restoring the per-file truncation/binary handling
  the original implementation had.

Adds GitCtr.test.ts covering quote/dequote round-trips for the problem
characters the reviewer called out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:46:09 +08:00
YuTengjing d0e99aada4 🐛 fix: stop router fallback for invalid requests (#14285) 2026-04-30 15:15:25 +08:00
Arvin Xu 13e8ef9c7b 💄 style(brief): show artifacts in card and extract DocumentModal (#14339)
*  feat(brief): show artifacts in card and extract DocumentModal

Wire `brief.artifacts` (already populated by topic-brief synthesis) into
TaskBriefCard and the home BriefCard so completed-topic deliverables
show up inline; clicking a doc card opens it in a modal.

The per-task PageModal becomes a reusable `DocumentModal` (props-based:
documentId/open/onClose), and the preview trigger state moves from task
store to a new `preview` slice in document store — any surface can now
call `useDocumentStore.openDocumentPreview(id)`.

Also:
- PageAgentPanelOverrideProvider: ephemeral right-panel state for
  PageEditor in transient surfaces (modal); defaults collapsed and
  doesn't write the persisted global preference.
- PageEditor.fullWidthHeader: layout flag so the modal's header spans
  both columns instead of the left pane only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(shared-tool-ui): unify label-to-content spacing in file inspectors

Replace trailing-space spacing with explicit 6px marginInlineEnd on the label
span in Read/Edit/Write/List inspectors so they match the 6px gap already used
by chip-based renderers (Bash, Grep, Glob).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(brief): clear preview state on document modal teardown

`previewDocumentId` is global (`useDocumentStore`) and the modal opens on
any truthy value. Without cleanup, navigating away with the modal open
left a stale id behind, and the next surface that mounted a preview
modal (e.g. /home daily brief) would immediately reopen the old doc.

Extract a `<DocumentPreviewModal />` connector that resets the preview
state on unmount, and use it everywhere the global preview should be
rendered (TaskDetailPage, DailyBrief). Future mount points get the
cleanup for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(brief): coerce globalExpand to boolean in panel control hook

`systemStatusSelectors.showPageAgentPanel` returns `boolean | undefined`
(zenMode short-circuit ANDs with an optional flag), but
`PageAgentPanelControl.expand` is `boolean`. Coerce with `!!` so the
non-override branch satisfies the type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:34:19 +08:00
CanYuanA 8387067807 🐛 fix: fix PDF chunking logic to prevent vectorization failure (#14327) 2026-04-30 13:55:36 +08:00
Tsuki 375e6381ce feat(mobile-router): add task and brief routers (#14337)
 feat(mobile-router): add task and brief routers to mobile tRPC router

Expose task and brief endpoints to the mobile client so the React Native
app can manage tasks and daily briefs via the same tRPC contract used by
the web client.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 13:09:38 +08:00
Arvin Xu f7c1ebf652 🚀 release: sync main branch to canary (#14317)
Automatic sync from main to canary. Merge conflicts detected.

**Resolution steps:**
```bash
git fetch origin
git checkout sync/main-to-canary-20260429-25113686179
git merge origin/main
# Resolve conflicts
git add -A && git commit
git push
```

> Do NOT merge canary into a main-based branch — always merge main INTO
the canary-based branch to keep a clean commit graph.
2026-04-30 13:08:39 +08:00
Arvin Xu 156a870cf3 🐛 fix(model-runtime): preserve LLM finishReason through callbacks transformer (#14336)
* 🐛 fix(model-runtime): preserve LLM finishReason through callbacks transformer

Soft interrupts from providers (Gemini RECITATION / MAX_TOKENS, etc.)
emit a `type: 'stop'` chunk carrying the finishReason string, but
`createCallbacksTransformer` was only using it as a terminal-event flag
and never aggregating the value. Downstream the `OnFinishData` payload
had no `finishReason` field, so RuntimeExecutors recorded an `llm_result`
event without it — the harness silently rendered an empty assistant
message even though tokens were billed.

Capture the value in the callbacks aggregator, surface it on
`OnFinishData`, and write it into the `llm_result` tracing event so
soft-interrupt cases are diagnosable.

Fixes LOBE-8403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(model-runtime): keep first finishReason across multi-stop streams

Anthropic emits two `'stop'` chunks per stream — `message_delta` with
the real `stop_reason` (`end_turn` / `max_tokens` / `tool_use`) followed
by a `message_stop` sentinel. Last-write-wins clobbered the meaningful
reason with the sentinel string, defeating the very tracing signal this
fix is meant to provide.

Switch to first-non-empty-wins so the real provider reason survives.
The empty-string fallback covers cases where an early provider chunk
arrives before the reason is known.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:46:30 +08:00
Innei f017dcd0ea test: fix home cold route CI coverage 2026-04-30 12:40:31 +08:00
lobehubbot 719a554456 🔖 chore(release): release version v2.1.55 [skip ci] 2026-04-30 12:37:58 +08:00
Innei 3b1eef72d8 🐛 fix(chat): preserve topics across cold route sends (#14284)
**Hotfix Scope:** Topic preservation across cold chat-entry routes

> Keeps newly created Topics visible when a first message is sent before
the destination chat route has fully hydrated.

- **Page Agent empty-session regression** — Sending the first message in
an empty Page Agent panel no longer clears the newly created Topic and
returns the panel to an empty state. (Resolves LOBE-8351)
- **Home cold-route send regression** — Sending from the Home default
Chat Input now routes to the newly created Inbox Topic even when
`/agent/:aid` has never been opened and the route chunk has no warm
cache.
- **Page-scoped Copilot consistency** — Page Copilot and File Copilot
share the same provider-level topic reset behavior, so stale Topics are
cleared only when entering or switching the scoped Agent.
- **Regression coverage** — Added focused unit coverage for Home default
sends, route parity coverage remains intact, and added an E2E scenario
for the no-cache Home send path.

- `bunx vitest run --silent='passed-only'
'src/routes/(main)/home/features/InputArea/useSend.test.ts'
'src/spa/router/desktopRouter.sync.test.tsx'
'src/routes/(main)/agent/features/Conversation/ChatHydration/index.test.tsx'
'src/routes/(main)/agent/_layout/AgentIdSync.test.tsx'`
- `BASE_URL=http://localhost:3007
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres bun
run test -- --tags '@HOME-CHAT-COLD-001'` from `e2e/`

- Self-hosted: pull the new image and restart. No schema or environment
changes.
- Cloud: ships through the normal hotfix deployment after merge.

@Innei

Fixes LOBE-8351
2026-04-30 12:37:58 +08:00
lobehubbot 9e20cd6b3a 🔖 chore(release): release version v2.1.54 [skip ci] 2026-04-30 12:37:18 +08:00
LobeHub Bot a5f4b4b569 🌐 chore: translate non-English comments to English in agent-runtime examples and siliconcloud provider (#14332)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:14:03 +08:00
LiJian 5a15f759d6 refactor(creds): add local/desktop credential injection guidance (#14306)
*  feat(creds): add local/desktop credential injection guidance

Teach AI how to use credentials in non-sandbox (desktop/local) environments via
getPlaintextCred + runCommand inline env vars, alongside the existing sandbox flow.

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

* 🔒 fix(creds): use runCommand env param for secure credential passing

Inline secrets in the command string would be visible in the Intervention UI
and logs. Use runCommand's env parameter instead, and correct the misleading
file credential guidance (getPlaintextCred returns a fileUrl, not a local path).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:07:29 +08:00
Arvin Xu b7ecf2fd4d feat(agent,working-sidebar): add Review tab with bulk git diffs (#14334)
 feat(agent,working-sidebar): add Review tab with bulk git working-tree diffs

Adds a Codex-style Review tab to the agent working sidebar (peer to the
existing Resources content, surfaced as Space). When the active topic has a
working directory bound, the sidebar shows two chip-style tabs — Space (left)
and Review (right) — and the Review pane lists every dirty file with its
unified diff rendered via PatchDiff.

A single new IPC method `git.getGitWorkingTreePatches(dirPath)` enumerates
the working tree once via `git status --porcelain -z`, then runs every
per-file `git diff` in parallel inside main; tracked entries hit
`git diff HEAD -- <file>` while pure untracked files use
`git diff --no-index /dev/null <file>`. Each patch is capped at 256 KB and
classified into added / modified / deleted with additions/deletions counts
parsed off the patch text, so the renderer needs exactly one round trip and
zero per-file fetches.

The Review pane defaults to all files expanded, with PatchDiff render gated
on the panel's expanded state so collapsed entries don't pay the shiki
highlight cost. Adds a unified/split viewMode toggle in the Review subheader,
shows an Unstaged-N chip alongside it, and ships a custom small expand caret.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:53:49 +08:00
Tsuki 24062bb412 💄 style(daily-brief): add skeleton loading state (#14333)
💄 style(daily-brief): add skeleton loading state for DailyBrief component

LOBE-8400

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 11:40:14 +08:00
LobeHub Bot 61d432a991 🤖 style: update i18n (#14330)
💄 style: update i18n

Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2026-04-30 10:17:41 +08:00
Arvin Xu f59954137a 💄 style(task): add start-scheduling button in automation popover (#14323)
*  feat(task): add start-scheduling button in automation popover

Lets users mark a configured task as "scheduled" without firing an
immediate run, so the cron/heartbeat tick owns the first execution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): hide start-scheduling button in heartbeat mode

Heartbeat tasks are re-armed only by maybeRearmHeartbeat after a topic
completes — there is no dispatcher that picks up `scheduled` heartbeat
tasks, so the button would leave a paused/backlog task dormant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 10:03:52 +08:00
Neko 1324b67590 ♻️ refactor(server): agent signal now is easier to use (#14326) 2026-04-30 05:36:26 +08:00
Neko f390d04ef2 🐛 fix(server): prefer to use tool call id first (#14322) 2026-04-30 05:07:51 +08:00
Arvin Xu 84df8a9994 ♻️ refactor(task-brief): auto-synthesize topic briefs (#14324)
*  feat(task-lifecycle): auto-synthesize topic briefs (LOBE-8333)

Replaces agent-driven createBrief on the non-review "done" path with a
programmatic synthesis: rule-based decision + DB-collected artifacts +
a dedicated LLM for user-facing title/summary. Handoff and brief stay
separate (agent-internal vs user-facing language) and the new path is
gated behind task.config.brief.mode === 'auto' so existing tasks keep
the legacy tool-driven behavior until the GrowthBook flag flips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(generate-brief): let LLM gate emission per topic content

Pure rules can only skip the obvious cases (error, judge-handled,
automation tick, trivial content). They can't tell that "I clarified
my understanding and will start drafting next" is a working note, not
a delivery. Add an `emit: boolean` to GENERATE_BRIEF_SCHEMA and have
the prompt instruct the model to judge — emit=false discards the
brief without writing to the table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(task-model): move topic-artifact query into TaskModel

DB queries belong on the model, not in a service helper. Replaces
the standalone collectTopicArtifacts() with TaskModel.getDocumentsPinnedSince(),
which lives next to pinDocument / getPinnedDocuments and returns
joined { id, kind, title } rows. synthesize.ts is now pure decision
logic — no more drizzle imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:58:39 +08:00
YuTengjing 9aea74659f 🐛 fix: restore task agent panel toggle (#14321) 2026-04-30 00:46:28 +08:00
Arvin Xu 105321bfe1 🐛 fix(file-loaders): support UTF-16 encoded text files in TextLoader (#13615)
* 🐛 fix: support UTF-16 encoded text files in TextLoader

The TextLoader previously hardcoded UTF-8 encoding when reading files,
causing UTF-16 encoded CSVs (e.g. Google Ads Keyword Planner exports)
to be parsed with null bytes, producing garbled content and database
insert failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor(file-loaders): tighten TextLoader UTF-16 detection

- Use TextDecoder('utf-16be') instead of manual byte-swap loop, which
  also avoided in-place mutation of the read buffer.
- Replace the 2-byte heuristic with a 512-byte sample, count ASCII-pair
  shape on both halves so UTF-16BE without BOM is detected too, and
  files whose first character is non-ASCII no longer slip through.
- Add tests for UTF-8 BOM, UTF-16LE no-BOM, and UTF-16BE no-BOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 23:59:50 +08:00
YuTengjing b0b6e67d5f feat: support DeepSeek Anthropic runtime (#14312) 2026-04-29 23:57:18 +08:00
YuTengjing d2aa3cd1b4 🐛 fix(model-bank): reject lobehub model ids no longer in the bank (#14261) 2026-04-29 23:52:11 +08:00
AmAzing- babdc6ade5 Fix task drawer agent metadata hydration (#14315) 2026-04-29 22:55:55 +08:00
YuTengjing 7e6255096a ♻️ refactor: use virtual model id for default onboarding model (#14311) 2026-04-29 22:44:20 +08:00
Neko 0e7eda4b47 feat(agent-signal,server,prompts,builtin-tool-memory): score based orchestration, consolidate better (#14314) 2026-04-29 22:42:58 +08:00
lobehubbot 71cfba9906 🔖 chore(release): release version v2.1.55 [skip ci] 2026-04-29 14:09:35 +00:00
Innei b8fe675508 🐛 fix(chat): preserve topics across cold route sends (#14284)
**Hotfix Scope:** Topic preservation across cold chat-entry routes

> Keeps newly created Topics visible when a first message is sent before
the destination chat route has fully hydrated.

## 🐛 What's Fixed

- **Page Agent empty-session regression** — Sending the first message in
an empty Page Agent panel no longer clears the newly created Topic and
returns the panel to an empty state. (Resolves LOBE-8351)
- **Home cold-route send regression** — Sending from the Home default
Chat Input now routes to the newly created Inbox Topic even when
`/agent/:aid` has never been opened and the route chunk has no warm
cache.
- **Page-scoped Copilot consistency** — Page Copilot and File Copilot
share the same provider-level topic reset behavior, so stale Topics are
cleared only when entering or switching the scoped Agent.
- **Regression coverage** — Added focused unit coverage for Home default
sends, route parity coverage remains intact, and added an E2E scenario
for the no-cache Home send path.

##  Verification

- `bunx vitest run --silent='passed-only'
'src/routes/(main)/home/features/InputArea/useSend.test.ts'
'src/spa/router/desktopRouter.sync.test.tsx'
'src/routes/(main)/agent/features/Conversation/ChatHydration/index.test.tsx'
'src/routes/(main)/agent/_layout/AgentIdSync.test.tsx'`
- `BASE_URL=http://localhost:3007
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres bun
run test -- --tags '@HOME-CHAT-COLD-001'` from `e2e/`

## ⚙️ Upgrade

- Self-hosted: pull the new image and restart. No schema or environment
changes.
- Cloud: ships through the normal hotfix deployment after merge.

## 👥 Owner

@Innei

Fixes LOBE-8351
2026-04-29 22:06:01 +08:00
Innei 990942fb45 feat(agent-marketplace): fetch onboarding templates from market API (#14286)
*  feat(agent-marketplace): implement onboarding agent marketplace picker

Adds a new builtin tool `@lobechat/builtin-tool-agent-marketplace` that
opens a categorized agent picker UI during web onboarding. The picker
fetches the live curated catalog from the marketplace API
(`/api/v1/agents/onboarding-full`) via a TRPC procedure that injects the
trust-token, and lets the user select template agents to install.

Highlights:

- Self-contained marketplace package with manifest, system role, executor,
  and ExecutionRuntime
- React intervention component with category sidebar, skeleton loading
  state, and avatar/empty/error UI; all user-visible strings i18n-driven
- Dependency-inverted fetcher: package exports `setAgentTemplatesFetcher`,
  app registers a TRPC-backed implementation in AgentOnboardingPage
- New TRPC `market.agent.getOnboardingFull` proxies the upstream API with
  trust-token authentication; client never sees secrets
- Splits the existing `saveUserQuestion` intervention into agent identity
  and user profile cards for clearer onboarding approval UX
- Wires marketplace into `builtin-tools` registry, executor map, and
  onboarding metrics; web-onboarding agent system prompt updated to
  reference the picker

Closes LOBE-7801

*  feat(onboarding): enhance early exit handling and marketplace integration in onboarding flow

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(agent-marketplace): register server runtime, scope picks per-topic, and harden onboarding handoff prompts

The summary phase silently skipped the marketplace handoff because the
server toolExecution registry had no runtime for `lobe-agent-marketplace`,
so every `showAgentMarketplace` call returned "not implemented" and the
agent fell through to `finishOnboarding`. The runtime-injected phase
guidance and action hints also instructed the agent to call
finishOnboarding directly after the summary, contradicting the new
system role.

- Register `agentMarketplaceRuntime` in
  `src/server/services/toolExecution/serverRuntimes` so the executor
  can actually run.
- Scope the in-memory `picks` map by `topicId` and reject a second
  `showAgentMarketplace` call in the same conversation with a clear
  "already opened, finish on next turn" message.
- Tighten the success content to instruct the model to STOP the current
  turn after opening the picker and run closing + finishOnboarding on
  the FOLLOWING user turn.
- Update `OnboardingActionHintInjector`, `PHASE_GUIDANCE.summary`,
  `toolSystemRole` and `web-onboarding/systemRole` so all four prompt
  layers agree: open the picker exactly once during summary, do not
  call finishOnboarding in the same turn, and do not call the
  submit/skip/cancel APIs ourselves.
- Stop treating short affirmations like "好的" / "行" / "ok" as
  early-exit signals; they are confirmation of the summary and should
  let the picker handoff proceed normally.

Verified end-to-end with `bun run agent-evals run onboarding/web-onboarding-v3
--case-id fe-intj-crud-v1 --model deepseek-v4-pro`: hard assertions all
pass, judge moves from 7/10 (premature finishOnboarding in same turn)
to 8/10 with picker opened once and finishOnboarding deferred to the
next turn.

* fix(ci): attempt 1 for PR #14286

Auto-generated by pr-dispatcher (task: 01KQBY8GAC1MNQCJ6T6X5DEP2F, attempt: 1).

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

* 🐛 fix(agent-marketplace): wire picker submit + fix marketplace-already-opened detection

The marketplace picker confirm flow was sending the user's selection back as a
synthetic user message, and the action hint kept telling the model to open the
marketplace again — leading to a death loop where the agent re-opened the
picker instead of summarizing + persisting + finishing onboarding.

Two issues:

1. Pick confirm forwarded the selection as a user message instead of forking
   the agents and resuming from the tool result. Wire `prepareCustomInteractionSubmit`
   into the intervention's submit branch so it runs `installMarketplaceAgents`
   client-side and returns a descriptive `toolResultContent`. Plumb a
   `createUserMessage: false` + `toolResultContent` option through
   `submitToolInteraction` (slice + chat store): when set, skip the synthetic
   user message, override the tool message content, and resume runtime from the
   tool message (`parentMessageType: 'tool'`) so the LLM sees the install
   result and continues from there.

2. `OnboardingActionHintInjector.marketplaceAlreadyOpened` read `msg.tool_calls`,
   but this provider runs in pipeline phase 4.5 (virtual tail guidance) BEFORE
   `ToolCallProcessor` (phase 5) converts DB-shape `tools` → OpenAI-shape
   `tool_calls`. Detection always returned false → the hint kept saying
   "call showAgentMarketplace" → death loop. Fix: match on `tools[].apiName`
   (with `tool_calls` kept as a fallback). Also rewrote the Summary-phase hints
   to reflect the new flow (picker resolves directly via tool result, no
   synthetic user reply needed).

Includes intervention bar portal-target plumbing for approval actions.

*  feat(onboarding): wire marketplace picker analytics on agent onboarding page

Mount AnalyticsBridge under AgentOnboardingPage to inject useAnalytics() into
setOnboardingAnalyticsClient, so onboarding_marketplace_shown/picked events
emit through PostHog instead of being silently dropped. Adds spm fields to
align with onboardingFeedback's telemetry shape.

* ♻️ refactor: move DEFAULT_ONBOARDING_MODEL to business-const

Made-with: Cursor

*  test(customInteractionHandlers): add tests for persisting marketplace picks and resolutions

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(onboarding): enhance agent marketplace integration with metadata persistence

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(agent): add web onboarding agent selectors and integrate into Actions and Usage components

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-29 21:25:16 +08:00
Innei ecec2e87e3 🐛 fix: use fileId for proxy URL in knowledge queries (#14051)
KnowledgeRepo queries use COALESCE(d.id, f.id) as id, which returns the
document's `docs_xxx` ID when a document exists for the file. Using this
as the proxy URL path (`/f/docs_xxx`) fails because the file proxy route
looks up the `files` table by `file_xxx` ID.

Fix: use `item.fileId` (always the actual file ID) for proxy URLs in
`getKnowledgeItems` and `recentFiles` handlers.

Closes #12196
2026-04-29 20:02:23 +08:00
Innei 7b6978271a feat(chat): support local file mention snapshots (#14278)
*  support local file mention snapshots

*  feat(local-file-mention): implement useLocalFileMention hook for local file search functionality

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix desktop project file index fallback

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-29 19:39:31 +08:00
YuTengjing 28c2e9002a 🔥 chore: drop useSkillConnection hook (moved into cloud feature) (#14308) 2026-04-29 19:32:15 +08:00
Rdmclin2 b9034ce9c1 🐛 fix: e2e page related tests (#14309)
* chore: add default home locales

* fix: e2e tests

* fix: LobeAI locales

* fix: Lobe AI locales

* fix: test case errors

* chore: update i18n files
2026-04-29 17:49:56 +07:00
Rdmclin2 2eb7ee824f feat: support Line (#14207)
* feat: support Line

* chore: update Line docs

* feat: support line platform

* chore: update markdown files

* fix: lint error

* fix: home padding block
2026-04-29 15:37:27 +07:00
YuTengjing e78949cd23 🐛 fix: reset task agent transient state (#14303) 2026-04-29 16:37:13 +08:00
Arvin Xu afae236628 🐛 fix(task): manual run no longer eats the next scheduled tick (#14304)
Daily/weekly schedules dedup'd by calendar day, so a manual "run now"
earlier in the day would advance lastHeartbeatAt and make the dispatcher
skip today's scheduled tick. Dedup now compares against today's target
H:M instead — a 21:00 schedule still fires after a 18:00 manual run,
while post-target runs and same-tick re-dispatch are still skipped.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:21:09 +08:00
Neko 8830c6d560 ♻️ refactor(server): prevent writing useless metadata into documents for agent signal managed skills (#14291) 2026-04-29 15:57:03 +08:00
Arvin Xu f42fc7d65d 🐛 fix: include all properties in task_topic_handoff response_format required (#14297)
Azure / OpenAI strict structured outputs require every key in `properties`
to appear in `required`; the schema only listed `title` and `summary`,
so every generateHandoff call returned 400 "Missing 'keyFindings'".

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:54:29 +08:00
Neko e5e154afcb ♻️ refactor(agent-signal): simplify structure of agent-signal (#14290) 2026-04-29 15:45:21 +08:00
Rdmclin2 346812ab88 🔨 chore: update i18n files & fix title skeleton (#14301)
* chore: update i18n files & fix title skeleton

* chore: update taskTemplate json

* chore: update i18n files
2026-04-29 13:23:26 +07:00
YuTengjing a099749b41 ♻️ refactor(taskTemplate): use string icon identifiers (#14302) 2026-04-29 13:54:41 +08:00
Arvin Xu fbe8ab3891 ♻️ refactor(context-engine): drop ____builtin suffix from tool names (#14289)
♻️ refactor(context-engine): drop ____builtin suffix from tool names

Builtin tools now generate two-segment names like documents____upsertDocumentByFilename instead of documents____upsertDocumentByFilename____builtin. The "default" plugin type was already suffix-less, and "default" is no longer in active use, so collapsing builtin into the same shape removes redundant LLM-facing tokens. resolve() falls back to type 'builtin' for two-segment names and still parses legacy three-segment ____builtin names from message history.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:25:24 +08:00
Arvin Xu 2965cbc83a docs(lobehub-skill): add video/image model lookup guide to generate & model references (#14264)
* docs(lobehub-skill): add video/image model lookup guide to generate reference

* docs(lobehub-skill): add full model type list and default-type warning to model reference

* docs(lobehub-skill): fix incorrect tip about lh model list default behavior

* 🐛 fix(builtin-skills): close template literal in model reference

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:04:04 +08:00
AmAzing- fc44aaef38 Persist model detail panel expansion state (#14294) 2026-04-29 10:35:13 +08:00
Arvin Xu a2b8f4c81a 🐛 fix: consolidate agent-documents tools and fix empty readDocument (#14288) 2026-04-29 09:33:48 +08:00
Arvin Xu 6f9f5643d1 feat: polish task list & detail, expose topic operation ID (#14282)
* ♻️ refactor: remove schedule config popup from task list item

The task list row should only display the schedule trigger tag, not act
as an entry point for editing the automation. Configuration stays
available on the task detail page via TaskProperties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style: mute BriefIcon when task is resolved

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style: flatten task markdown card, drop container background and padding

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat: expose task topic operationId and add copy menu item

Surfaces the persisted `task_topics.operationId` through the task detail API
so the topic card menu can offer a "Copy operation ID" entry alongside
"Copy topic ID", aiding debugging of completed runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix: skip empty text block when Claude Code prompt is image-only

Anthropic rejects `{ text: '', type: 'text' }` with "messages: text content blocks must be non-empty", so uploading an image with no text would 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat: add topic actions menu and share button to task topic drawer

- Add "..." dropdown next to title with Copy topic ID / Copy operation ID
- Add Share icon next to close button, reusing SharePopover and ShareModal
- Pass topicId through SharePopover so it works outside the chat store scope
- Use getContainer={false} on Drawer to escape App's isolation stacking context, letting popups render above the drawer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:40:38 +08:00
yueyinqiu e4877436fe uncomment image / video / text2music in modelTypeOptions (Form.tsx) (#14275) 2026-04-29 02:32:16 +08:00
Zhijie He 04775f66ff 💄 style: migrate Hunyuan to TokenHub for Hy3 Preview (#14108)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-04-29 02:31:21 +08:00
Neko 9fff5fccf0 feat(app,server,agent-signal,cli): new policy for Skill management running inside of Agent Signal (#14281) 2026-04-29 02:18:57 +08:00
Rdmclin2 5a46c5a971 feat: refactor home (#14266)
* feat: refactor home

* feat: add home agent id switch

* fix: useSend ensure agent map init

* feat: add custom image/video generation menu item

* chore: remove agent list ,group list and modetag

* fix: default home agent fallback

* fix: built in agent builder creation

* feat: add deepseek pro v4 hot picks

* chore: support agent select scrolling

* feat: add bot integration banner

* fix: lint error

* chore: update home page styles

* chore: adjust padding

* test: add image item to sidebar items test fixtures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: remove obsolete home starter e2e tests

The mode-tag buttons (Create Agent / Create Group / Write) no longer
exist after the Home refactor, so these scenarios cannot run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:57:22 +07:00
YuTengjing 5722b7159b feat: add task manager copilot (#14272) 2026-04-29 01:21:40 +08:00
Zhijie He 49a71bed6e 🐛 fix: expose CRAWLER_TIMEOUT env for crawler (#14274)
chore: expose CRAWLER_TIMEOUT env for crawler
2026-04-29 00:06:53 +08:00
Neko d5511a6af2 feat(cli,server,database): now agent document can be used as vfs, offer fs compatible output (#14222) 2026-04-28 23:50:50 +08:00
Neko e46e81a08a test(server): should not use adhoc Date.now() (#14280) 2026-04-28 23:43:15 +08:00
Arvin Xu 9555e4fda3 feat: task card, agent profile nav, CC streaming, view switcher polish (#14277)
* 💄 style(home): collapse empty suggest questions wrapper on default home

Why: when enableAgentTask is on, SuggestQuestions and CommunityRecommend both render null on the default home view, but the AnimatePresence wrapper still mounted with marginTop:24 and produced a large empty gap between StarterList and DailyBrief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task): add cron-based task schedule dispatcher

Wires up backend execution for task-level cron schedules. Adds two
QStash workflows-hono routes:

- POST /api/workflows/task/schedule-dispatch — central sweep, point a
  QStash Schedule (e.g. */30 * * * *) here. Loads all schedule-mode
  tasks, filters by cron pattern + timezone + lastHeartbeatAt dedup,
  and fans out per-task messages.
- POST /api/workflows/task/schedule-execute — internal per-task handler
  that re-validates DB state and runs the task via TaskRunnerService.

Reuses existing schedulePattern / scheduleTimezone columns and
lastHeartbeatAt for dedup — no migration needed. Failure paths fall
through to the existing onTopicComplete error handling (urgent brief
+ paused).

* 💄 style(task): collapse resolved brief card on detail by default

Why: resolved briefs on the detail page rarely need re-reading; matching
home's collapse-when-resolved behavior keeps the activity feed compact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(agent-profile): make popup header navigate to agent profile

Click on the avatar/title in AgentProfilePopup now closes the popup and routes to /agent/:id/profile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task): render task XML as a card in topic chat drawer

Why: the topic drawer's first user message is the task run prompt — a `<task>...</task>` XML blob (identifier, status, instruction, agent, …). Rendering it as raw XML buries the structure the user actually cares about.

- Add a `Task` markdown plugin (scope: user) that parses the `<task>` payload and renders an Artifacts-style card.
- Use a custom remark plugin so the block survives mdast splitting it across html + paragraph nodes.
- Gate the card UI behind a `TaskCardScope` React Context so it only activates inside `TopicChatDrawer`; everywhere else falls back to a plain `<pre>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(claude-code): reuse result renders during streaming via wrapRender

Why: while a CC tool is still executing, the detail view fell back to a generic argument table for everything except `Agent`. Read/Write/Edit/Glob/Grep/Skill/Bash/TodoWrite already gracefully degrade their result Render when `content`/`pluginState` are absent, so the same component works for the live phase too.

- Add `wrapRender` helper that adapts a `BuiltinRender` into a `BuiltinStreaming` by passing `content: null`.
- Register Bash/Edit/Glob/Grep/Read/Skill/TodoWrite/Write streaming entries through `wrapRender`. `Agent` keeps its bespoke streaming view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(task-subtasks): drop legacy blockedBy flattening branch

Why: subtasks now always arrive as a real tree from the upstream service, so the fallback that re-built the tree from a flat list via `blockedBy` is dead code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(view-switcher): hide chat/task switcher for heterogeneous agents

Why: the chat/task view switcher in the agent header doesn't apply when the agent is heterogeneous (Claude Code / Codex / etc.) — those agents don't share the task topic flow, so showing the switch surfaces a non-functional control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task-topic): show elapsed duration on completed topic runs

Mirror task_topics terminal transitions (completed / failed / canceled / timeout)
onto topics.completedAt so the activity feed can render elapsed time for
finished runs, not just for the live one. Thread completedAt through
findWithHandoff and the TaskDetailActivity payload, then extend TopicCard
to render formatDuration(completedAt - createdAt) for non-running statuses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task-trigger-tag): respect automationMode when rendering schedule label

Heartbeat tasks were displaying cron schedule text when the DB still carried
a schedulePattern from a previous mode. Switch to automationMode as the
source of truth in TaskTriggerTag and pass it from all three call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:21:45 +08:00
Neko 729fbc72d5 🔨 chore(agent-signal,app): added tracing to agent signal, ensure traceparent propagate to handler (#14212) 2026-04-28 22:54:03 +08:00
Neko 0e1a55f2f8 🔨 chore(.agents): added skill for agent-signal (#14206) 2026-04-28 22:53:16 +08:00
Arvin Xu c1e2d134ed 🐛 fix(conversation): stop topic scroll restore from corrupting its own snapshot (#14247)
* 🐛 fix(conversation): stop topic scroll restore from corrupting itself

The restore path called scrollTo(snapshot.offset) one rAF after a fresh
VList mount, when only viewport-visible items had laid out. virtua
clamped the target against the still-incomplete scrollSize and landed
at offset 0, then the resulting onScroll fed back into recordScroll and
overwrote the snapshot to offset 0 — locking the user at the top on
every revisit.

Two fixes:
- Add a restoringRef guard that suppresses recordScroll while a
  programmatic restore is in flight, released after two rAFs.
- Poll virtua's scrollSize for up to 30 frames until it can accommodate
  the target offset before issuing scrollTo, with a safety bail-out so
  unreachable offsets still resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(conversation): converge scroll snapshot to clamped offset on cap-out

When the saved offset is unreachable (e.g. messages were trimmed since
the snapshot was written), the polling loop hits its 30-frame cap and
falls through to scrollTo(targetOffset). Without this fix, the snapshot
keeps the stale unreachable offset, so every future revisit pays the
full polling delay before clamping again.

After the cap-out scrollTo lands, read the actual scrollOffset and
persist it (with a recomputed atBottom). Reachable-target restores still
leave the snapshot untouched so we don't churn writes for no reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:51:36 +08:00
Innei 8663991c7c feat: auto-dismiss upload dock after completion (#14055)
*  feat: auto-dismiss upload dock after completion

UploadDock now auto-removes all completed files and hides itself 3 seconds
after all uploads finish (or error). If new uploads start during the timer,
the timer is cancelled and the dock stays visible.

Closes #9605

* fix(ci): 将 `useRef<ReturnType<typeof setTimeout>>()` 改为 `useRef<ReturnType<typeof setTimeout> | null>(null)`。

Auto-generated by pr-dispatcher (task: 01KQ9ZB50GQXWTYADHAWEGTNQR, attempt: 1).

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

* fix(ci): Guarded `clearTimeout(autoDismissTimerRef.current)` calls with `if (autoDismissTimerRef.current)` checks in the UploadDock auto-dismiss effect.

Auto-generated by pr-dispatcher (task: 01KQA0NZB57SFPHP45227ENZAT, attempt: 1).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-28 21:59:19 +08:00
Coooolfan 35edca5531 🐛 fix: render intervention fallback avatar as image (#14271) 2026-04-28 21:45:36 +08:00
Arvin Xu 101b9f9973 💄 style(task): task detail polish (#14269)
* 💄 style(task): replace page drawer with modal and rebuild artifact card

- Migrate page preview from a right-side drawer to a centered modal
  (`PageModal`) with allow-fullscreen support; rename store state
  `activePageDrawerPageId` → `activePageModalId` and the corresponding
  `openPageDrawer` / `closePageDrawer` actions / selectors.
- Refresh artifact cards: collapse to a single-line layout (smaller
  file icon, inline size + identifier tag) and add a remove action
  that calls `unpinDocument` against the artifact's `sourceTaskId`
  fallback chain (so artifacts pinned from another task unpin from
  the right task, not just the active one).
- Surface `sourceTaskId` on `TaskDetailWorkspaceNode` /
  `WorkspaceDocNode` and through the task service so the renderer
  can resolve the owning task for the unpin call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(brief): add delete action for brief cards

- `briefService.delete` calls `brief.delete` mutation; `deleteBrief`
  store action removes the brief from the in-memory list after the
  server roundtrip.
- `TaskBriefCard` exposes a `MoreHorizontal` dropdown with a danger
  delete item gated by an `App.confirm` modal; `TaskActivities`
  passes `onAfterDelete=refreshActiveTask` so the activity list
  re-fetches once the brief is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): use local timezone over DB-default UTC on first schedule enable

The `tasks` table seeds `schedule_timezone` to `'UTC'` on row creation, so
even a task that has never been scheduled surfaces `timezone='UTC'`. The
previous "if timezone is missing, use local" check therefore never fired,
and first-time schedule enable always defaulted to UTC.

Treat a missing `pattern` as the reliable signal that the user has never
opened the schedule form, and override the DB-default UTC with the user's
local IANA zone in that case. A user-chosen timezone (with a real
pattern) is still preserved on subsequent re-entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task/scheduler): replace TimePicker with half-hour Select

- Cron storage rounds minutes to 0/30 (see `buildCronPattern`), so the
  picker only ever needs 48 half-hour slots — flatten antd's
  hour×minute grid into a single-column `Select`.
- Anchor every dropdown (`getPopupContainer`) inside the parent Base UI
  Popover so option clicks aren't treated as outside-clicks (which
  dismissed the popover before the selection committed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task/subtasks): wire context menu via Tree.onRightClick

`ContextMenuTrigger` was attached to each subtask title's inner
`Flexbox`, but antd `Tree`'s row-level `.ant-tree-node-content-wrapper`
only `preventDefault`s the contextmenu event when an `onRightClick`
handler is provided. Right-clicks landing in the row gap (anywhere
outside the title element) fell through to the browser's native menu.

- Refactor `useTaskItemContextMenu` into a shared
  `useTaskContextMenuActions` factory exposing stable
  `buildItems(task)` / `installKeyboardHandlers(task)`. Existing
  `useTaskItemContextMenu(task)` API is preserved as a thin wrapper.
- `TaskSubtasks` now calls `Tree.onRightClick`, looks up the subtask
  by `node.key` from a recursively-built map (subtasks are returned
  as a nested tree, not flat), and calls `showContextMenu` plus the
  keyboard-handler installer imperatively.
- The flat-map walk is recursive so right-click works on nested
  children, not just top-level subtasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task/topic): wrap dropdown to swallow card click + relabel topic ID

- Wrap the topic card's `MoreHorizontal` dropdown in a `Flexbox`
  with `onClick={stopPropagation}` so menu interactions don't
  bubble through to the card-level click handler.
- Fix the menu label fallback: `Copy run ID` → `Copy topic ID` to
  match what the action actually copies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task/artifacts): also refresh active task SWR after unpin

`unpinDocument` is called with `node.sourceTaskId` (the task that
owns the pin row, often a descendant DB id), but the open detail
page's SWR cache is keyed by `activeTaskId` (typically the parent
identifier from `/task/{identifier}`). Refreshing only the source
key left the parent's workspace stale until reload.

After the unpin succeeds, also revalidate the active key when it
differs from the source. The server call still uses the source id
because `model.unpinDocument` deletes by exact `(taskId, documentId)`
match — passing the parent identifier would no-op for docs pinned
by a subtask.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(panel): give page and task right panels independent visibility

Page editor and Task layout now read/write `showPageAgentPanel` /
`showTaskAgentPanel` (with matching `togglePageAgentPanel` /
`toggleTaskAgentPanel` actions) instead of sharing the global
`showRightPanel`, so toggling one no longer flips the other. Task panel
defaults to collapsed; page panel stays open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task/detail): tighten artifact size label and align activity card padding

- artifact size shows raw count with "字" instead of "1.4k 字符"
- swap artifact file icon to FileTextIcon (lucide), 18px
- BriefCard padding 12 → paddingInline 8 to align with CommentInput; BriefIcon 20 → 24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(task/page-modal): give modal its own header via PageEditor slot

PageEditor now accepts an optional `header` slot (undefined keeps the
built-in Header, null hides it). PageModal stops relying on antd's title
chrome and supplies its own header — title + autosave on the left, panel
toggle and close on the right — so the modal no longer stacks two
headers and owns its own composition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(page): mirror document into pageStore on standalone fetch

Document fetch now upserts the loaded `page`-source document into
pageStore via a new `upsertDocument` action. PageExplorer reads title
and emoji from pageStore selectors, so opening a page from a context
that never hit the page list (e.g. the task workspace modal) used to
show empty title/emoji until the list was visited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:50:28 +08:00
Innei c6a013a1a1 🐛 fix(home): restore welcome typewriter stability (#14270) 2026-04-28 19:12:41 +08:00
YuTengjing 19643ba662 feat(task-template): add home recommendation system with skill connect (#14214) 2026-04-28 18:11:00 +08:00
Arvin Xu 2654c4d31e 💄 style(task): polish schedule, artifacts, and task list UI (#14248)
* 💄 style(task): polish schedule popover

Refresh the schedule popover after design review:

- Header: avatar with  icon + summary (e.g. "Runs every 10 min" / "Daily
  at 09:00 · China Standard Time"); next-run preview block under the title.
- Segmented tabs gain Calendar / Refresh icons; Recurring tab drops the
  Clear button + advanced section (only Schedule mode keeps advanced).
- Advanced settings is now an Accordion (matches lobehub patterns) and
  hosts timezone + max executions.
- All inputs switch to variant="filled"; weekday picker uses
  colorPrimaryBg + colorPrimary instead of solid primary to fix the
  white-on-white "burned" active state.
- Popover surface uses colorBgContainer + colorBorderSecondary border +
  12px radius for clearer elevation.

New `scheduler/helpers.ts` formats the cron summary, resolves IANA
timezone display names via Intl, and computes the next firing time for
both heartbeat and cron schedules (uses dayjs/plugin/timezone).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): hide standalone "Brief" fallback in task list

When a brief activity has no title/summary AND no briefType, the latest
activity line on the task list rendered just "Brief" / "简要" — useless
text with no actual content. Return undefined in that case so the line
is omitted entirely.

Drops the now-unused `taskDetail.latestActivity.briefOnly` key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task): navigate to /page/:id when clicking artifact tree

Drop `selectable={false}` on the workspace tree and wire `onSelect` to
push `/page/<documentId>`, so artifacts are openable from the task
detail page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): enforce 10-minute minimum on recurring interval

Drop the Seconds unit from the Recurring tab so users can't schedule
sub-minute intervals (which the runner can't keep up with anyway), and
clamp existing values that are smaller than 10 minutes to 10 minutes
when the popover opens.

Drops the now-unused `taskSchedule.seconds` key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): surface needs-review group above backlog in task list

Reorder the default kanban/list groups so `needsInput` (paused + failed)
sits at the top — the list view stacks groups vertically, and putting
actionable items first means users see what needs attention before
scrolling past long backlogs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): catch up next heartbeat firing past stale lastAt

When `lastAt + interval` already lies in the past (e.g. task was paused
for hours), step forward by whole intervals so the returned time is
strictly after now. Otherwise the popover would show a stale
"next run" timestamp until the next tick lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task): open artifact pages in right-side drawer

Replace the `/page/:id` navigation from the artifact tree (a4af053338)
with a right-side drawer that shows the page in-place — the same UX
pattern as the chat document portal, so users keep the task context
while previewing artifacts.

- New `PageDrawer` mirrors `TopicChatDrawer` styling (right-anchored
  floating drawer with rounded edges + shadow). Renders `PageExplorer`
  inside.
- Task store gains `activePageDrawerPageId` state with
  `openPageDrawer` / `closePageDrawer` actions; opening a page also
  closes the topic drawer so the two don't stack on the same edge.
- `TaskArtifacts.onSelect` now calls `openPageDrawer(documentId)`
  instead of pushing a new route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): seed defaults when entering an automation mode

Switching to a mode without persisting its core fields left the task in
a "mode enabled but unconfigured" state — the popover showed
"自动化未启用" / "Automation is off" because schedulePattern was still
null even though the Schedule tab was active, and the cron runtime had
nothing to fire.

`setAutomationMode` now seeds:
- `heartbeatInterval = 600` (10 min) when entering heartbeat without one
- `schedulePattern = '0 9 * * *'` + `scheduleTimezone = 'UTC'` when
  entering schedule mode without them

Existing values are preserved on subsequent mode toggles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): default scheduleTimezone to user's local IANA zone

Hardcoding `UTC` meant a user in Shanghai who picked "Daily 09:00" on a
fresh task would actually fire at 17:00 local. Resolve the user's local
zone via `Intl.DateTimeFormat().resolvedOptions().timeZone` (with a UTC
fallback for environments where Intl is unavailable) so the seeded
default matches what the user expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): polish list, detail, and schedule UI

- Always show top-right + button in kanban view (no inline create input there)
- Unify subtasks/artifacts/activities section indicator on the Accordion arrow
- Refresh schedule popover nextRun every minute and move styling to staticStyles
- Move paused/failed groups ahead of running/backlog in task list ordering
- Color the scheduled status icon with colorWarning to match other active states

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(gateway): gate reconnect on server URL, not user toggle

Resuming a Gateway-running operation should depend on whether the server has
a Gateway URL configured — the user's lab toggle controls *new* requests, not
reattaching to an op that's already running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(task): surface scheduled state with cancel action and countdown

- Reorder list view group ranks so paused/failed (待审阅) sit above
  running and backlog, matching the kanban needsInput-first layout.
- Map `scheduled` task status to the running group so cron/heartbeat
  tasks waiting between ticks no longer fall through to backlog.
- Render a muted "Scheduled" pill on task list rows so users can tell
  scheduled (waiting) apart from running (executing now) at a glance.
- Add a "Cancel schedule" action and live countdown to the task detail
  page when status=scheduled; cancel disables automation AND moves the
  task back to backlog so the status badge updates immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): redesign artifact list as flat cards with file icons

Replace the antd Tree-based artifact view with a flat list of clickable
outlined cards. Each card uses FileIcon (resolves a real file glyph from
the title's extension) and shows the artifact title, size, and source
task tag inline. Removes the unused folder/tree visualization since
workspace nodes today are effectively flat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): use warning color for scheduled status icon

Promote the scheduled status icon from `colorTextDescription` to
`colorWarning` so it visually groups with `running` (also warning) — both
states represent "automation in progress" and now share a consistent
warm color, matching how kanban groups them in the same column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(topic): use shared MAIN_SIDEBAR_EXCLUDE_TRIGGERS constant

Replace the local EXCLUDE_TRIGGERS array with the canonical
MAIN_SIDEBAR_EXCLUDE_TRIGGERS exported from `@/const/topic` so the chat
sidebar and any other consumers stay aligned on which trigger types are
hidden from the main topic list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): rename artifact label from 作品 to 产物 in zh-CN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): align artifact cards with activities content width

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(brief): collapse resolved brief cards by default

Resolved brief cards now show only the header row with a "marked as resolved" badge and an expand chevron; clicking the chevron reveals the summary and actions. Also tightens the collapsed summary max-height from 240 to 180.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): show human-readable schedule on trigger tag

The list/properties trigger tag rendered the raw cron pattern
("0 9 * * * (Asia/Shanghai)") which is unreadable for non-engineers.
Reuse the popover's `formatScheduleDescription` + `formatTimezoneName`
helpers so the tag now reads as e.g. "每天 09:00 执行 · 中国标准时间".

The raw cron + IANA id moves into the tooltip for users who need it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): split timezone onto a smaller secondary line

The schedule summary used to read "每天 09:00 执行 · 中国标准时间" on a
single line, which crowded the popover header and the inline trigger tag
in TaskProperties. Move the timezone onto its own line below the
description with a smaller font and `colorTextDescription`, so the
primary information (when it fires) reads cleanly first.

For the compact pill (`mode='tag'`) used in the task list, drop the
visible timezone entirely — it stays accessible via the tooltip
alongside the raw cron pattern.

Drops the now-unused `taskSchedule.summary.schedule` interpolation key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): default to schedule mode + reword automation copy

- Toggle "自动化" on now lands in the Schedule tab (cron) instead of the
  Heartbeat tab. A scheduled run is the more common, predictable choice
  — users who want fixed intervals can switch tabs from there.
- Rename the heartbeat tab from "循环任务"/"Recurring" to "心跳模式"/
  "Heartbeat" so the term matches the underlying mechanism (and the
  existing `taskSchedule.tag.heartbeat` copy).
- Replace 执行 with 运行 across the schedule UI strings (持续执行 → 持
  续运行, 执行频率 → 运行频率, 下次执行 → 下次运行, etc.) for a more
  natural "run" framing.
- Drop dead keys `taskSchedule.interval` and `taskSchedule.schedulerNotReady`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(brief): resolve brief and re-run task on free-form feedback

The SquarePen feedback editor only called addComment, leaving the
urgent brief unresolved — so the heartbeat re-arm gate kept skipping
the task with reason=human-waiting and the card never moved. Switch
the path to submitFeedback (resolveBrief + task.run) so the agent
picks up resolvedComment on the next turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(task): make trigger tag hover human-readable too

The pill already shows "每天 09:00 运行", but the tooltip still leaked
the raw cron + IANA id ("0 9 * * * (Asia/Shanghai)") on hover. Replace
it with a single readable line using "·" as separator, e.g.
"每天 09:00 运行 · 中国标准时间".

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:31:16 +08:00
Innei b94aa1da90 feat(chat): route leading agent mentions directly (#14237)
*  feat(chat): route leading agent mentions directly

* 🐛 fix(chat): propagate thread flag for direct mention runtime
2026-04-28 17:14:16 +08:00
Rdmclin2 e896024b68 feat: optimize bot cli & userId guide (#14258)
* chore: add userId and serverId tooltip guide

* feat: update built in message tool

*  feat(cli): add bot dm-policy / allowlist subcommands (LOBE-8254)

Extend `lh bot update` with --dm-policy / --group-policy / --user-id /
--server-id, and add new `lh bot allowlist` and `lh bot group-allowlist`
subcommand groups (list/add/remove/clear). All write paths read existing
settings first and merge so unrelated keys aren't wiped by the partial
update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(channel): warn when a saved bot is missing the operator userId

Surface an inline alert and auto-expand the Advanced Settings group when an
existing bot has no settings.userId — without it AI tools can't push
notifications back to the operator and pairing approvals fail silently.
Skip on first-time configs and on platforms that don't expose userId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: optimize userId alert

* fix: test case

* fix: footer effective userId

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:14:51 +07:00
Innei 2835b99d1a 🐛 fix(conversation): inline single-tool assistant group and promote leading sentence (#14244)
Made-with: Cursor
2026-04-28 15:16:02 +08:00
YuTengjing 47812b2be9 🐛 fix(user-state): include notification settings in getUserState (#14257) 2026-04-28 11:33:07 +08:00
René Wang 798644414a 📝 docs: add April 27 weekly changelog (#14249) 2026-04-28 11:04:51 +08:00
YuTengjing 54bb83f229 feat(aihubmix): add gpt-image-2 and Grok 4.20 models (#14253) 2026-04-28 10:57:49 +08:00
Octopus 65da232c64 fix(utils): preserve JPEG format when compressing uploaded images (#13585)
* 🐛 fix(utils): preserve JPEG format when compressing uploaded images

Images with dimensions > 1920px were always re-encoded as PNG regardless
of original format, inflating small JPEGs (100–200 KB) to 1 MB+ because
PNG is lossless while JPEG is lossy.

Fix: pass file.type to compressImage(), encode JPEG inputs as JPEG at
0.85 quality (not PNG), and derive File MIME type from the data URL
instead of hardcoding 'image/png'.

PNG and WebP inputs still compress to PNG as before.

Fixes #13485

*  test(utils): add tests for JPEG format preservation in compressImage

Per @tjx666's request on #13585. Adds explicit coverage for the JPEG
format-preservation behaviour:

- compressImage with type='image/jpeg' calls toDataURL with quality 0.85
- compressImage with type='image/png' calls toDataURL without a quality arg
- compressImage with no type defaults to PNG
- compressImageFile preserves JPEG inputs as image/jpeg (regression fence
  for the previously hardcoded 'image/png' MIME type in dataUrlToFile)
- compressImageFile keeps WebP inputs as PNG (documents the fallback)

The existing PNG tests are preserved to guard against regression in the
lossless path.

---------

Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-04-28 10:33:22 +08:00
BillionToken dacc7798ab fix(image): preserve resolution when changing aspect ratio (#13324)
Co-authored-by: BillionClaw <267901332+BillionClaw@users.noreply.github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-04-28 10:27:57 +08:00
Zhijie He 9508807da7 💄 style: add build-in websearch for Volcengine via ResponseAPI (#14216) 2026-04-28 10:18:39 +08:00
Zhijie He 6a7eb17cd2 💄 style: update batch of models (#14070) 2026-04-28 10:17:17 +08:00
YuTengjing c5da34b680 🔨 chore: refresh team assignment guide (#14243) 2026-04-28 10:15:18 +08:00
Arvin Xu 2a37b77482 ♻️ refactor(recent): rewrite queryRecent in Drizzle, exclude web-tool scrapes (#14239)
* ♻️ refactor(recent): rewrite queryRecent in Drizzle, exclude web-tool scrapes

- Replace raw SQL UNION with Drizzle's typed unionAll (topicArm/documentArm/taskArm)
- Hoist filter lists into named constants (SYSTEM_TOPIC_TRIGGERS,
  TOOL_DOCUMENT_SOURCE_TYPES, TASK_FINAL_STATUSES) for readability
- Recent now excludes documents whose sourceType is in ('file', 'web') so
  web-browsing tool scrapes stop leaking in alongside file uploads
- Add RecentModel test coverage

* 🐛 fix(recent): widen TOOL_DOCUMENT_SOURCE_TYPES to const tuple for inArray

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:24:16 +08:00
Arvin Xu b814cf2611 feat(task): scheduled status + cron schedule editor (#14246)
*  feat(task): support scheduled status for cron-driven automation

Adds the new `scheduled` task status to the type system, lifecycle, and
UI so cron-driven tasks can park between ticks instead of falling back
to `paused`. Replaces the SchedulerTab placeholder with a real cron
editor (frequency / weekday / time / timezone / max runs) and surfaces
the schedule config through TaskDetailData.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): show full execution history in detail

`findWithHandoff` defaulted to a limit of 4, which fits the prompt-build
case but truncated the activity feed in the task detail UI to the latest
4 runs. Make `limit` required and pass 100 from the detail service so
scheduled tasks display their full run history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(QueueTray): use elevated surface tokens

Switch the queue tray's border to colorFillSecondary and its background
to colorBgElevated so it visually sits above the chat input rather than
blending into the page background.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:17:43 +08:00
LiJian c37817e2d8 🐛 fix: add the lobehub cli oidc expreis should refresh aksk (#13925)
* fix: add the lobehub cli oidc expreis should refresh aksk

* fix: add the buffer seconds
2026-04-28 00:47:25 +08:00
Arvin Xu bbf239705c 🐛 fix(send-message): forward topic-list filter to server response (#14160)
* 🐛 fix(send-message): forward topic-list filter to server response

Without this, sending a message refreshes `topicDataMap` with an
unfiltered list, so completed/cron topics flash back into the sidebar
until the next SWR revalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(topic): preserve filter fields in internal_updateTopics

internal_updateTopics rewrote topicDataMap[key] from scratch and dropped
excludeStatuses / excludeTriggers, so #getTopicFilter returned undefined
on the second sendMessageInServer call and stopped forwarding the filter
to the server — completed/cron topics could leak in until SWR
revalidated. Carry the filter fields forward from currentData, matching
loadMoreTopics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:31:38 +08:00
Innei 8a9f42596d 📝 docs(version-release): add hotfix changelog example and patch scenario [skip ci] (#14242)
📝 docs(version-release): add hotfix example and patch scenario note

Made-with: Cursor
2026-04-27 23:43:35 +08:00
lobehubbot 682657ba50 🔖 chore(release): release version v2.1.54 [skip ci] 2026-04-27 15:41:37 +00:00
sxjeru 29235dc1ed 💄 style: interactive improvement of model search (#14192)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-04-27 23:41:28 +08:00
lobehubbot e326400dbe Merge remote-tracking branch 'origin/main' into canary 2026-04-27 15:39:18 +00:00
Innei deeb97ab5b 🐛 hotfix: clear stale topic when switching agents from a topic route (#14231)
* 🐛 fix(agent): clear stale topic in store when switching agents

Switching agents from `/agent/agt_A/tpc_X` to `/agent/agt_B` left the
previous topic's messages on screen and made *Start new topic* feel
inert. Two fixes:

- ChatHydration: replace the `useEffect`-based `useStoreUpdater` with
  `useLayoutEffect` so the URL→store sync of `activeTopicId` /
  `activeThreadId` runs before paint. Otherwise Conversation paints
  one frame against the prior agent's `activeTopicId` and only catches
  up on the next render. Also handles `null` (rather than `undefined`)
  so the store actually clears instead of silently retaining the stale
  id.
- AgentPage (desktop + web): drive the topic-popup guard from
  `useParams().topicId` instead of the store, since URL is the source
  of truth for which topic to render.

Drops the now-unnecessary `Portal` import from the desktop variant.

* 🐛 fix(conversation): update context handling and improve thread list visibility logic

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(topic): update ThreadList to accept topicId prop and improve thread visibility logic

Signed-off-by: Innei <tukon479@gmail.com>

*  test(topic): align topic item thread list mock

* 🐛 fix(agent): show active thread title in conversation header

Header `Tags` always read `topicSelectors.currentActiveTopic(s)?.title`,
so when navigating into a subtopic (`activeThreadId` set via the
`?thread=...` URL sync) the title bar still showed the parent topic's
name. Read the matching thread from `s.threadMaps[s.activeTopicId]`
when `activeThreadId` is set and fall back to `chat:thread.title`
("Subtopic") for unnamed threads.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-27 23:35:51 +08:00
sxjeru d73858ef42 💄 style: add GPT-5.5 and GPT-5.5 Pro models (#14142)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-04-27 23:27:28 +08:00
sxjeru 6b9584714d 🐛 fix(Action): model params panel closes unexpectedly during auto-save (#14198)
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 23:18:49 +08:00
Arvin Xu b9a4a9093c 🐛 fix(topic): drop switchTopic race under rapid sidebar clicks (#14115)
* 🐛 fix(topic): drop switchTopic race under rapid sidebar clicks

Share the single-click debounce timer at module level so a click on any
topic cancels a pending click from another, and add an epoch guard in
ChatTopicActionImpl.switchTopic so stale refresh continuations cannot
flip activeTopicId back to a superseded topic.

Fixes LOBE-7785

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(topic): yield before refresh so switchTopic epoch can skip stale fetches

The post-await epoch check was dead code: nothing followed the await.
Yield a microtask before the refresh so queued switchTopic sync bodies
can bump #switchTopicEpoch first, then bail the superseded caller before
its SWR mutate ever fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:17:21 +08:00
Arvin Xu ef5be7e17c fix(cli): clarify asyncTaskId vs generationId in gen status/download + better error message (#14230)
* 🔖 chore(release): release version v2.1.53 [skip ci]

* fix(cli): improve gen status/download error message for wrong asyncTaskId

* docs(cli-skill): clarify asyncTaskId vs generationId in gen status/download

* fix(builtin-skills): clarify asyncTaskId vs generationId in gen status/download

* fix(cli): distinguish asyncTaskId not found vs generationId not found in error message

* Update package.json

---------

Co-authored-by: lobehubbot <i@lobehub.com>
2026-04-27 23:16:05 +08:00
Innei a4235d3f68 ⬆️ chore: upgrade desktop Electron to 41.3.0 (#14223)
* ⬆️ chore: upgrade desktop electron to 41.3.0

* 🐛 fix: patch ant design icons vitest resolution

* 🐛 fix: require fixed ant design icons version
2026-04-27 21:54:23 +08:00
AmAzing- fa508f4259 feat: add agent-specific topic grouping mode and improve empty state… (#14225) 2026-04-27 21:52:10 +08:00
YuTengjing 94767fddcb 🐛 fix(utils): keep tiny prices visible in formatPrice (#14235) 2026-04-27 20:20:53 +08:00
Arvin Xu 685b17e59e 💄 style(tasks): detail polish round + heartbeat webhook fix + notif deep-link (#14228)
*  feat(tasks/progress): align workspace progress visibility with chat input

Switch the right-side ProgressSection to selectCurrentTurnTodosFromMessages so it appears and disappears in lockstep with the TodoProgress bar above ChatInput, instead of lingering on stale historical todos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks): promote tasks entry into top-level header nav

Place the Tasks entry directly under Home in the sidebar header alongside Search/Home, instead of letting it float inside the customizable body list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks/comment): use filled background for the task detail comment input

Switch the task detail comment input from a bordered card on `colorBgElevated` (which read as outline-only in light mode) to a `colorFillTertiary` filled card so it looks consistently filled in both light and dark themes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  fix(tasks/progress): restore keyboard toggle & uncap expanded list

Address two regressions introduced when ProgressSection moved off Accordion:

- Re-add keyboard/ARIA semantics on the toggle (role=button, tabIndex, aria-expanded, aria-controls, Enter/Space handler) so keyboard and screen-reader users can collapse/expand the panel. Toggle now lives on the header row only, so clicking todos no longer collapses the panel.
- Replace the `max-height: 600px` cap with the `grid-template-rows: 0fr → 1fr` pattern, letting the list grow to its natural height. Long todo plans are no longer clipped; the parent sidebar (already `overflow-y: auto`) handles scrolling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(tasks/documents): auto-pin agent-created documents to current task

Why: taskDocuments table and TaskModel.pinDocument exist with pinnedBy='agent',
but the agent-documents runtime never called pinDocument, so documents written
during a task were never linked to that task's workspace.

How: In agentDocumentsRuntime factory, read context.taskId and pin the new
documentId after createDocument / createTopicDocument / copyDocument /
upsertDocumentByFilename. Idempotent via the existing (taskId, documentId)
unique constraint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(tasks/artifacts): render task-level artifacts on the detail page

Why: The taskDocuments table now auto-populates when an agent writes a doc,
and the backend already serves the workspace tree (own task + descendants)
via getTaskDetail. The detail page just wasn't rendering it yet.

How: New TaskArtifacts component reads activeTaskWorkspace and shows a
collapsible tree (file/folder + size + source-task tag). Selectable is off
for now — click-through interaction will land in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(tasks/lifecycle): deliver onTopicComplete webhook via QStash

The hook was registered without `delivery: 'qstash'`, defaulting to plain
fetch. The target route `/api/workflows/task/on-topic-complete` is mounted
under `qstashAuth()`, which rejects unsigned requests with 401 in
production. `HookDispatcher.fetchDeliver` only logs failures, so the
webhook silently failed — leaving topic.status stuck at 'running' forever
for every heartbeat (and regular) task in production.

Same fix applied to all four agentEvalRun webhook registrations for
consistency, even though those routes are currently unauthenticated.

LOBE-8303

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(desktop/notification): deep-link notification click to source chat

Resolve the SPA path (group / 1:1 topic / agent root) from the conversation
context when posting a desktop notification, and forward it through the
existing main-broadcast `navigate` pipeline so clicking the notification
brings the user back to the originating chat instead of just focusing the
window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks): move tasks tab back into the customizable sidebar

Removes 'tasks' from the fixed header nav and re-adds it as a default,
user-reorderable item under the body sidebar (alongside pages / recents).
Reverts the header-promotion from 287a3ac815 in favor of letting users
place / hide the tab themselves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(tasks/detail): introduce TaskBriefCard, polish topic row layout

- Split a dedicated TaskBriefCard for the detail timeline so brief styling
  there can diverge from the daily-brief card without conditionals.
- Promote the agent avatar (with profile popup) to the TopicCard header,
  drop the redundant author chip and calendar icon next to the timestamp.
- Move the dashed divider from BriefCardSummary into BriefCard so any
  consumer of the summary block doesn't get an unexpected leading rule.
- Tighten card padding (CommentCard / TopicCard) to align with the timeline
  rhythm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(agent/header): round segmented control items in ViewSwitcher

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:50:18 +08:00
YuTengjing 9acb128943 📝 docs(skills): rename code-review to review-checklist (#14229) 2026-04-27 18:17:16 +08:00
Arvin Xu ee55d74dd4 💄 style(tasks): drop custom actions on result briefs & show trigger tag in subtasks (#14226)
 feat(tasks): drop custom actions on result briefs & show trigger tag in subtasks

- Result briefs render a fixed single-button UI, so reject custom actions at
  brief creation time and remove the unused defaults / lifecycle actions.
- Surface automation trigger (heartbeat / schedule) on subtask rows by
  threading the fields through TaskService → TaskDetailSubtask → tree.
- Polish: tree title flex/overflow fix, QueueTray send icon swapped to ArrowUp.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:44:03 +08:00
YuTengjing cca1050e82 🐛 fix: localize provider moderation generation errors (#14220) 2026-04-27 15:22:56 +08:00
Arvin Xu 92a848c69c feat(tasks/brief): subtask avatar polish, brief actions revamp & task drawer Gateway reconnect (#14208)
* 💄 style(task): right-align subtask assignee avatar and make it clickable

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(brief): standardize result brief actions to mark-as-done + edit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief): align decision brief icon with kanban pending-review column

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(brief): rename result brief primary action to "Confirm complete"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(tasks): wire passive Gateway WS reconnect for the task topic drawer

The task topic drawer rendered messages from the DB but never connected
to the Gateway, so a running task showed only the initial prompt and the
empty assistant placeholder. Server already writes runningOperation into
topic metadata; expose it through TaskDetailActivity and reuse the main
agent reconnect hook so the drawer establishes the WebSocket on open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(brief): mute Check icon on resolved success tag

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(recent): exclude system-trigger topics from the Recent sidebar

The Recent SQL union pulled every topic regardless of trigger, so cron,
eval, task_manager, and task-runner topics leaked into the main "最近"
list alongside ordinary chats. Filter them in the topics SELECT, and
align the long-stale `TopicTrigger.RunTask` constant with the literal
`'task'` that TaskRunnerService actually writes (the const was unused
so no DB migration is needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:54:19 +08:00
Arvin Xu f32fff19dd 📝 docs(skills): record contributor roster in version-release (#14219)
📝 docs(skills): record contributor roster in version-release skill

- Add Contributor Ordering section with the canonical LobeHub team roster (10 handles) and a flat-list rule (community first, team after, sorted by PR count desc).
- Note the git-author-name vs GitHub-handle pitfall (e.g. YuTengjing -> @tjx666) and how to verify via gh CLI.
- Drop commits count from the changelog template's metadata and contributors lines; reword the contributors intro to a "Huge thanks to N contributors" pattern.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:23:04 +08:00
lobehubbot 38d7bdbd96 Merge remote-tracking branch 'origin/main' into canary 2026-04-27 05:19:09 +00:00
Rdmclin2 3e236ec36f feat: support dm pair policy (#14211)
* feat: support pair dm policy

* feat: add enum descriptions

* chore: optimize labels and copy

* chore: update i18n

* fix: lint error

* chore: update bot docs

* fix: peek paring request and so on issues
2026-04-27 11:31:07 +07:00
2773 changed files with 251786 additions and 38605 deletions
+2
View File
@@ -1,6 +1,8 @@
---
name: add-provider-doc
description: Guide for adding new AI provider documentation. Use when adding documentation for a new AI provider (like OpenAI, Anthropic, etc.), including usage docs, environment variables, Docker config, and image resources. Triggers on provider documentation tasks.
disable-model-invocation: true
argument-hint: '[provider-name]'
---
# Adding New AI Provider Documentation
+2
View File
@@ -1,6 +1,8 @@
---
name: add-setting-env
description: Guide for adding environment variables to configure user settings. Use when implementing server-side environment variables that control default values for user settings. Triggers on env var configuration or setting default value tasks.
disable-model-invocation: true
argument-hint: '[setting-name]'
---
# Adding Environment Variable for User Settings
+95
View File
@@ -0,0 +1,95 @@
---
name: agent-signal
description: Build or extend LobeHub Agent Signal pipelines for background or quiet agent work driven by event sources, semantic signals, and action handlers. Use when adding a new Agent Signal source, signal or action type, policy, middleware handler, workflow handoff, dedupe or scope behavior, or observability around `src/server/services/agentSignal/**`, `packages/agent-signal`, or `packages/observability-otel/src/modules/agent-signal`.
---
# Agent Signal
Use this skill to implement event-driven background work for agents without coupling the work to the foreground chat request.
Agent Signal has one consistent shape:
`source event` -> `signal interpretation` -> `action execution` -> built-in result signals
## Start Here
1. Read `references/architecture.md` to map the package boundary, runtime queue, scope model, and async workflow handoff.
2. Read `references/handlers.md` before writing any new policy, source handler, signal handler, or action handler.
3. Read `references/observability.md` when you need tracing, metrics, debugging, or workflow snapshot visibility.
## Use The Right Entry Point
- Use `emitAgentSignalSourceEvent(...)` when a server-owned producer should execute the pipeline immediately.
- Use `executeAgentSignalSourceEvent(...)` when a worker or controlled backend path already owns execution timing and may inject a runtime guard backend.
- Use `enqueueAgentSignalSourceEvent(...)` when the caller should return quickly and let Upstash Workflow process the event out-of-band.
- Use `emitAgentSignalSourceEventWithStore(...)` for isolated tests or evals that should avoid ambient Redis state.
Read:
- `src/server/services/agentSignal/index.ts`
- `src/server/workflows/agentSignal/index.ts`
- `src/server/workflows/agentSignal/run.ts`
## Core Model
- `source`: A normalized fact that happened. Sources come from producers such as runtime lifecycle events, user messages, or bot ingress.
- `signal`: A semantic interpretation derived from one source or from another signal. Signals express meaning, routing, or policy state.
- `action`: A concrete side effect planned from one signal. Actions do the work.
- `policy`: An installable middleware bundle that registers source, signal, and action handlers.
- `procedure`: Not a distinct runtime node. Treat "procedure" as the end-to-end flow for one use case: ingress source, matching handlers, planned actions, execution result, and observability.
Keep the boundaries strict:
- Add a new `source` when the outside world produced a new event.
- Add a new `signal` when the system needs a reusable semantic interpretation.
- Add a new `action` when the runtime needs a concrete side effect.
- Add or update a `policy` when you are wiring those pieces together.
## Implementation Workflow
1. Decide whether the use case is synchronous or quiet background work.
2. Define or reuse a source type in `src/server/services/agentSignal/sourceTypes.ts`.
3. Define or reuse signal and action types in `src/server/services/agentSignal/policies/types.ts`.
4. Implement handlers with `defineSourceHandler`, `defineSignalHandler`, or `defineActionHandler`.
5. Bundle handlers with `defineAgentSignalHandlers(...)`.
6. Register the policy in `src/server/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
7. Add or update ingress code that emits or enqueues the source event.
8. Add observability and tests before considering the flow complete.
## Default Reading Set
- Shared semantic core:
`packages/agent-signal/src/index.ts`
`packages/agent-signal/src/base/builders.ts`
`packages/agent-signal/src/base/types.ts`
- Server-owned runtime and middleware:
`src/server/services/agentSignal/runtime/AgentSignalRuntime.ts`
`src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
`src/server/services/agentSignal/runtime/middleware.ts`
`src/server/services/agentSignal/runtime/context.ts`
- Existing policy example:
`src/server/services/agentSignal/policies/analyzeIntent/index.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
`src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- Observability:
`src/server/services/agentSignal/observability/projector.ts`
`src/server/services/agentSignal/observability/traceEvents.ts`
`packages/observability-otel/src/modules/agent-signal/index.ts`
## Implementation Rules
- Reuse existing source, signal, and action types before adding new ones.
- Keep source handlers focused on interpretation and fan-out, not heavy side effects.
- Keep action handlers responsible for side effects, idempotency, and executor-style result reporting.
- Use stable ids and idempotency keys when the same source can arrive more than once.
- Preserve scope discipline. The runtime uses `scopeKey` to serialize related background work.
- Prefer the dedicated shared package types and builders from `@lobechat/agent-signal` for normalized nodes and result contracts.
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `src/server/services/agentSignal/**/__tests__` are the reference pattern.
## References
- Architecture and boundaries: `references/architecture.md`
- Writing handlers and policies: `references/handlers.md`
- Observability, metrics, and debugging: `references/observability.md`
@@ -0,0 +1,4 @@
interface:
display_name: 'Agent Signal'
short_description: 'Build AgentSignal sources, signals, actions, and policies.'
default_prompt: 'Use $agent-signal to add a new Agent Signal source, policy, handler, or observability flow.'
@@ -0,0 +1,199 @@
# Agent Signal Architecture
## Pipeline
Use this mental model first:
```text
producer
-> emitAgentSignalSourceEvent(...) or enqueueAgentSignalSourceEvent(...)
-> emitSourceEvent(...)
-> dedupe + scope lock + source normalization
-> runtime.emitNormalized(source)
-> source handlers
-> signal handlers
-> action handlers
-> built-in result signals
-> observability projection + persistence
```
The scheduler is queue-driven, not hard-coded for one policy:
```text
source node
-> matching source handlers
-> dispatch signals/actions
-> matching signal handlers
-> dispatch more signals/actions
-> matching action handlers
-> ExecutorResult
-> signal.action.applied | signal.action.skipped | signal.action.failed
```
Read:
- `src/server/services/agentSignal/index.ts`
- `src/server/services/agentSignal/sources/index.ts`
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
## Package Boundaries
### `packages/agent-signal`
Treat this as the shared semantic core.
It provides:
- base node types: source, signal, action
- builders: `createSource`, `createSignal`, `createAction`
- built-in result signal types
- runtime result contracts such as `RuntimeProcessorResult` and `ExecutorResult`
Read:
- `packages/agent-signal/src/base/types.ts`
- `packages/agent-signal/src/base/builders.ts`
- `packages/agent-signal/src/types/events.ts`
- `packages/agent-signal/src/types/builtin.ts`
### `src/server/services/agentSignal`
Treat this as the server-owned implementation layer.
It owns:
- source catalogs and payload maps
- policy-specific signal and action catalogs
- middleware registration
- runtime scheduling and guard backends
- Redis-backed dedupe, waypoint, and policy state
- service entrypoints for synchronous and async execution
### `packages/observability-otel/src/modules/agent-signal`
Treat this as shared OTEL ownership for Agent Signal metrics and tracer instances.
## Core Vocabulary
### Source
A source is the normalized external fact that started the chain.
Examples:
- `agent.user.message`
- `runtime.before_step`
- `runtime.after_step`
- `client.runtime.start`
- `bot.message.merged`
Define source payloads in:
- `src/server/services/agentSignal/sourceTypes.ts`
Build normalized sources in:
- `src/server/services/agentSignal/sources/buildSource.ts`
- `packages/agent-signal/src/base/builders.ts`
### Signal
A signal is a semantic interpretation. Signals should be reusable and meaning-oriented.
Examples from `analyzeIntent`:
- `signal.feedback.satisfaction`
- `signal.feedback.domain.memory`
- `signal.feedback.domain.prompt`
- `signal.feedback.domain.skill`
Define server-owned signal types in:
- `src/server/services/agentSignal/policies/types.ts`
### Action
An action is a concrete side effect the runtime should execute.
Example:
- `action.user-memory.handle`
Action handlers usually:
- check idempotency
- call tools, models, or services
- return `ExecutorResult`
### Policy
A policy is an installable bundle of handlers. It is the composition unit that turns the generic runtime into a feature.
Example:
- `createAnalyzeIntentPolicy(...)`
### Procedure
"Procedure" is not a first-class type in this runtime. Use the word to describe one end-to-end use case:
1. define ingress source
2. emit or enqueue the source
3. interpret source into signals
4. plan actions from signals
5. execute actions
6. persist trace and metrics
When a user asks for "the procedure", document the flow above and point to the exact producer, handlers, and execution entrypoint.
## Scope, Deduping, And Quiet Background Work
`scopeKey` is the serialization boundary for related work. It is used for:
- source dedupe windows
- scope locks during source generation
- runtime guard state
- waypoint persistence for queued processing
Read:
- `src/server/services/agentSignal/sources/index.ts`
- `src/server/services/agentSignal/runtime/context.ts`
- `src/server/services/agentSignal/constants.ts`
Use `enqueueAgentSignalSourceEvent(...)` when the work should stay quiet and out-of-band. That path:
1. normalizes the source envelope
2. derives or reuses `scopeKey`
3. triggers `AgentSignalWorkflow`
4. executes later in `runAgentSignalWorkflow`
This is the preferred path when the UI request should finish immediately and the policy can run in the background.
Read:
- `src/server/workflows/agentSignal/index.ts`
- `src/server/workflows/agentSignal/run.ts`
## Existing Example: `analyzeIntent`
Use `analyzeIntent` as the reference chain:
```text
agent.user.message
-> feedback satisfaction source handler
-> signal.feedback.satisfaction
-> feedback domain signal handler
-> signal.feedback.domain.*
-> feedback action planner
-> action.user-memory.handle
-> signal.action.applied | skipped | failed
```
Read:
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
@@ -0,0 +1,228 @@
# Writing Handlers And Policies
## Fluent Registration API
Use the middleware helpers in `src/server/services/agentSignal/runtime/middleware.ts`.
They provide:
- `defineSourceHandler(...)`
- `defineSignalHandler(...)`
- `defineActionHandler(...)`
- `defineAgentSignalHandlers(...)`
These helpers do two jobs:
1. keep handler registration terse
2. preserve strong typing when `listen` points at concrete source, signal, or action types
## Handler Shape
Each handler receives:
- the current runtime node
- `RuntimeProcessorContext`
The context gives you:
- `scopeKey`
- `now()`
- `runtimeState.getGuardState(lane)`
- `runtimeState.touchGuardState(lane, now?)`
Read:
- `src/server/services/agentSignal/runtime/context.ts`
## Return Contracts
Return one of these shapes:
- `void`: no fan-out, stop at this handler
- `{ status: 'dispatch', signals?, actions? }`: continue the chain
- `{ status: 'wait', pending? }`: pause for later host coordination
- `{ status: 'schedule', nextHop }`: schedule another hop
- `{ status: 'conclude', concluded? }`: stop with a terminal runtime result
- `ExecutorResult`: only for action handlers that performed a concrete side effect
Read:
- `packages/agent-signal/src/base/types.ts`
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
## Policy Composition Pattern
Use `defineAgentSignalHandlers([...])` to bundle related handlers into one policy.
Example from `analyzeIntent`:
```ts
return defineAgentSignalHandlers([
createFeedbackSatisfactionJudgeProcessor(...),
createFeedbackDomainJudgeSignalHandler(...),
createFeedbackActionPlannerSignalHandler(),
defineUserMemoryActionHandler(...),
]);
```
That bundle is later passed into the runtime via:
- `createDefaultAgentSignalPolicies(...)`
- `createAgentSignalRuntime({ policies })`
Read:
- `src/server/services/agentSignal/policies/index.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
## Source Handler Pattern
Use a source handler when you are interpreting a producer event into semantic signals.
Reference:
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
Pattern:
```ts
return defineSourceHandler(
AGENT_SIGNAL_SOURCE_TYPES.agentUserMessage,
'agent.user.message:my-handler',
async (source, ctx): Promise<RuntimeProcessorResult | void> => {
// interpret source payload
// optionally use ctx.runtimeState
return {
signals: [
/* one or more semantic signals */
],
status: 'dispatch',
};
},
);
```
Write source handlers when:
- a raw message, lifecycle event, or bot ingress needs interpretation
- the work is still semantic, not side-effectful
## Signal Handler Pattern
Use a signal handler when one semantic state should branch into more semantic states or planned actions.
References:
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
Pattern:
```ts
return defineSignalHandler(
MY_SIGNAL_TYPE,
'signal.my-policy-router',
async (signal): Promise<RuntimeProcessorResult | void> => {
return {
actions: [
/* planned work */
],
status: 'dispatch',
};
},
);
```
Use signal handlers for:
- routing
- fan-out
- filtering
- conflict resolution
- converting interpretation into planned actions
## Action Handler Pattern
Use an action handler when the runtime should do actual work.
Reference:
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
Pattern:
```ts
return defineActionHandler(
MY_ACTION_TYPE,
'action.my-policy-executor',
async (action, ctx): Promise<ExecutorResult> => {
// run service/tool/model side effect
// check idempotency if needed
return {
actionId: action.actionId,
attempt: {
completedAt: ctx.now(),
current: 1,
startedAt,
status: 'succeeded',
},
status: 'applied',
};
},
);
```
Keep these rules:
- perform idempotency checks here or immediately before side effects
- return stable `actionId`
- include failure detail in `error`
- let the scheduler turn the `ExecutorResult` into built-in result signals
## Source, Signal, And Action Type Placement
Use this split:
- external event payloads:
`src/server/services/agentSignal/sourceTypes.ts`
- policy-owned signal and action payloads:
`src/server/services/agentSignal/policies/types.ts`
- normalized shared node contracts:
`packages/agent-signal/src/base/types.ts`
Do not put app-specific signal catalogs into `packages/agent-signal`. That package should stay generic and reusable.
## Choosing The Right Node
Choose `source` when:
- the outside world emitted a new fact
Choose `signal` when:
- the system needs semantic meaning that downstream handlers can reuse
Choose `action` when:
- the runtime is ready for a concrete side effect
If a handler both interprets meaning and performs side effects, split it. That keeps chains inspectable and testable.
## Testing Strategy
Prefer focused tests near the touched code.
Useful references:
- `src/server/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
- `src/server/services/agentSignal/__tests__/index.integration.test.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/__tests__/*`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
Test at the smallest level that proves the behavior:
- handler unit test for one routing rule
- runtime test for queue fan-out
- integration test for service ingress and observability persistence
@@ -0,0 +1,118 @@
# Observability And Debugging
## OTEL Ownership
Use `packages/observability-otel/src/modules/agent-signal/index.ts` for the shared tracer and metrics.
Available instruments:
- `tracer`
- `sourceCounter`
- `signalCounter`
- `actionCounter`
- `actionResultCounter`
- `chainCounter`
- `signalActionTransitionCounter`
- `chainDurationHistogram`
- `actionDurationHistogram`
Use this module when you need shared telemetry ownership instead of creating feature-local meters or tracers.
## Projection Pipeline
After runtime execution, the service projects one compact observability model from the full chain.
Read:
- `src/server/services/agentSignal/observability/projector.ts`
- `src/server/services/agentSignal/observability/traceEvents.ts`
- `src/server/services/agentSignal/observability/store.ts`
Projection outputs:
- a trace envelope with source, signals, actions, results, edges, and handler runs
- a compact telemetry record with dominant path, status breakdown, and chain metadata
This projection is built from:
- source node
- emitted signals
- planned actions
- executor results
## How To Inspect A Chain
Use this order:
1. Inspect the source type and payload.
2. Inspect emitted signals.
3. Inspect planned actions.
4. Inspect executor results.
5. Inspect projected edges and dominant path.
The helper `toAgentSignalTraceEvents(...)` flattens a chain into compact event records suitable for tracing snapshots.
## Workflow Snapshot Bridge
Workflow-triggered runs do not naturally pass through the normal foreground runtime snapshot path, so `runAgentSignalWorkflow` adds a development-only bridge into `.agent-tracing/`.
Read:
- `src/server/workflows/agentSignal/run.ts`
Use that path when:
- the source was enqueued with `enqueueAgentSignalSourceEvent(...)`
- you need local trace visibility for quiet background work
## Common Debug Questions
### The source emits but nothing happens
Check:
- feature gate enabled for the user
- source type matches a registered source handler
- dedupe or scope lock did not short-circuit generation
Read:
- `src/server/services/agentSignal/index.ts`
- `src/server/services/agentSignal/sources/index.ts`
### The signal exists but no action runs
Check:
- the signal type has a registered signal handler
- the signal handler returns `status: 'dispatch'`
- the handler actually returned actions
### The action runs twice
Check:
- source dedupe key stability
- action idempotency strategy
- scope key stability across retries and workflow handoff
Reference:
- `src/server/services/agentSignal/policies/actionIdempotency.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
### Background runs are hard to discover
Check:
- workflow snapshot bridge in development
- projected telemetry record contents
- OTEL counters and histograms in the shared module
## Minimal Completion Checklist
- source ingress is testable
- handler registration is discoverable from the policy factory
- action executor returns structured results
- projection includes the new path cleanly
- tests cover at least one happy path and one no-op or failure path
-298
View File
@@ -1,298 +0,0 @@
---
name: bot
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
---
# Bot System
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
## Supported Platforms
| Platform | id | Default mode | Markdown | Edit | Notes |
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
**Multi-mode connection** — Slack/Feishu/Lark/QQ ship as websocket but support `webhook` per-provider via `settings.connectionMode`. The runtime always merges schema defaults into stored settings before resolving the mode (`resolveBotProviderConfig` / `resolveConnectionMode` in `platforms/utils.ts`), so the schema's `field.default` is the source of truth — set it correctly when adding a new multi-mode platform.
## Inbound Flow (one webhook → reply)
```
Platform server
│ POST /api/agent/webhooks/[platform]/[appId]
route.ts ── catch-all `[[...appId]]` route
BotMessageRouter (singleton)
│ • lazy-loads bot per `platform:applicationId`
│ • merges schema defaults + provider.settings (mergeWithDefaults)
│ • builds Chat SDK Chat<any> with createIoRedisState (if Redis available)
│ • registerHandlers: onNewMention / onSubscribedMessage / onNewMessage(/.dm)
│ • registerCommands: /new (reset topic), /stop (interrupt)
chatBot.webhooks[platform](req) ← Chat SDK parses → fires events
AgentBridgeService.handleMention / handleSubscribedMessage
│ • activeThreads guard (no duplicate runs per thread)
│ • adds 👀 reaction (eyes), startTyping
│ • merges debounced/queued skipped messages (mergeSkippedMessages)
│ • extractFiles (buffer → fetchData → url)
│ • formatPrompt (sanitize mention + speaker tag + referenced_message)
├── In-memory mode ──► AiAgentService.execAgent({ stepCallbacks })
│ → onAfterStep edits progress message live
│ → onComplete edits final reply, splits via splitMessage(charLimit)
└── Queue mode (isQueueAgentRuntimeEnabled) ──► execAgent({ stepWebhook, completionWebhook, webhookDelivery: 'qstash' })
→ returns immediately, callbacks land at /api/agent/webhooks/bot-callback
```
The router caches loaded bots in memory. Cache is **invalidated** by `BotMessageRouter.invalidateBot(platform, appId)` whenever the TRPC `update`/`delete` mutations run, so new credentials/settings take effect on the next webhook.
## Execution Modes
### In-memory (default)
`AgentBridgeService.executeWithInMemoryCallbacks` wraps `execAgent` with `stepCallbacks`. Lives in one process — Promise-based wait, 30-min timeout, edits the same `progressMessage` after every step. Topic title is summarized inline via `SystemAgentService`.
### Queue (`isQueueAgentRuntimeEnabled`)
`AgentBridgeService.executeWithWebhooks`:
1. Posts the `renderStart` placeholder, captures `progressMessageId`.
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
`/api/agent/webhooks/bot-callback/route.ts` verifies the QStash signature and hands off to `BotCallbackService.handleCallback`:
- `type: 'step'``handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
- `type: 'completion'``handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
`BotCallbackService.createMessenger` reloads provider + credentials from DB and rebuilds a `PlatformClient` per call (no in-memory state).
## Commands
Defined in `BotMessageRouter.buildCommands` and registered via two paths:
- **Native slash commands** (Slack/Discord): `bot.onSlashCommand('/<name>', ...)`
- **Text-based fallback** (Telegram/Feishu/QQ/Lark/WeChat): `bot.onNewMessage(/^\/(new|stop)(\s|$|@)/, ...)` plus a per-mention `tryDispatch` so commands work even before subscribe.
Built-in commands:
- `/new` — clears `topicId` in thread state, next message starts a fresh topic.
- `/stop` — interrupts the active execution (calls `AiAgentService.interruptTask` if `operationId` is known; otherwise queues a deferred stop via `requestStop`/`pendingStopThreads`, also aborts the startup phase via `startupControllers`).
To add a command, append to `buildCommands` — it auto-registers everywhere; on Telegram it also surfaces in the `/` menu via `client.registerBotCommands``setMyCommands`.
## Active-thread State (statics on `AgentBridgeService`)
- `activeThreads: Set<threadId>` — prevents duplicate runs per thread (must guard before stale-topic check, otherwise concurrent messages can drop).
- `activeOperations: Map<threadId, operationId>` — needed by `/stop` once `execAgent` returns.
- `startupControllers: Map<threadId, AbortController>` — cancels pre-`operationId` work (topic/tool prep).
- `pendingStopThreads: Set<threadId>``/stop` arrived before `operationId` existed; consumed once available.
In **queue mode**, the bridge `finally` skips cleanup so the marker persists until `BotCallbackService.handleCompletion` calls `clearActiveThread`.
## Topic Lifecycle in Threads
- `handleMention` always treats the message as the start of a new conversation.
- `handleSubscribedMessage` reads `topicId` from `thread.state`. If the topic is stale (`> 4 hours` since `updatedAt`), state is cleared and it retries as a fresh mention.
- If `execAgent` fails with a Postgres FK violation on `topic_id` (cached topic was deleted), the bridge clears state and retries as a mention.
- `subscribe()` is gated by `client.shouldSubscribe(threadId)` — Discord top-level channels return `false` so we don't follow up there.
## Attachments
`AgentBridgeService.extractFiles` resolves attachments in priority order:
1. `att.buffer` — already downloaded by the adapter (WeChat/Feishu inbound).
2. `att.fetchData()` — adapter-provided lazy download with auth (Telegram, Slack, Feishu history). **Required** when URLs are token-protected — naive `fetch(url)` later in `ingestAttachment.ts` has no credentials.
3. `att.url` — public CDN fallback (Discord, public QQ).
`inferMimeType` / `inferName` patch Telegram-style `photo` payloads (no `mimeType`/`name` from Bot API → defaults to `image/jpeg`) so vision models actually see them. Quoted-message attachments are also pulled from `raw.referenced_message.attachments` (Discord).
## Concurrency
`settings.concurrency` is `'queue'` or `'debounce'`:
- `debounce` → Chat SDK debounces inbound messages by `debounceMs`; `mergeSkippedMessages` joins skipped texts/attachments into the current message before handing to the agent.
- `queue` → Chat SDK serializes per-thread; the bridge's own `activeThreads` set is still required because in queue mode the SDK lock releases before the agent finishes.
## Gateway (persistent platforms)
Webhook platforms run fine in serverless functions. Persistent platforms (`websocket`, `polling`) need a long-running listener — that's the **gateway**.
**`GatewayService.startClient(platform, appId, userId)`** (`src/server/services/gateway/index.ts`):
- On Vercel + persistent mode → `BotConnectQueue.push` (Redis hash) and mark runtime status `queued`. The cron picks it up.
- On Vercel + webhook mode → start the client inline (one HTTP call).
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
**`GET /api/agent/gateway/route.ts`** (cron, `Bearer ${CRON_SECRET}`):
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
**`POST /api/agent/gateway/start/route.ts`** is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
## Platform Definitions
Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
```ts
{
id: 'discord',
name: 'Discord',
connectionMode: 'websocket', // recommended default
schema: FieldSchema[], // applicationId + credentials + settings
clientFactory: new DiscordClientFactory(),
supportsMarkdown?: boolean, // default true
supportsMessageEdit?: boolean, // default true
documentation?: { portalUrl, setupGuideUrl },
}
```
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `serverIdField`, `userIdField`).
Each platform implements `PlatformClient` (see `platforms/types.ts`):
- Lifecycle: `start(opts?)`, `stop()`
- Inbound: `createAdapter()` → Chat SDK adapter map
- Outbound: `getMessenger(platformThreadId)``{ createMessage, editMessage, removeReaction, triggerTyping, updateThreadName? }`
- Formatting: `formatMarkdown?`, `formatReply?` (usage-stats footer when `showUsageStats`)
- Helpers: `extractChatId`, `parseMessageId`, `sanitizeUserInput`, `shouldSubscribe`, `resolveReactionThreadId`
- Optional patches: `applyChatPatches(chatBot)` (Discord uses this for `forwardedInteractions` + `threadRecovery`)
- Optional menu: `registerBotCommands(commands)` (Telegram `setMyCommands`)
`ClientFactory.validateCredentials` is called from the TRPC `testConnection` mutation — implement it to hit the platform API and return useful per-field errors.
## Database
**Schema** (`packages/database/src/schemas/agentBotProvider.ts`):
```ts
agent_bot_providers (
id uuid pk,
agent_id text fk agents.id (cascade),
user_id text fk users.id (cascade),
platform varchar(50), // 'discord' | 'slack' | …
application_id varchar(255),
credentials text, // KeyVaults-encrypted JSON
settings jsonb default '{}',
enabled boolean default true,
timestamps
)
unique (platform, application_id)
```
**Model** (`packages/database/src/models/agentBotProvider.ts`):
- User-scoped: `create / update / delete / query / findById / findByAgentId / findEnabledByApplicationId`. Credentials are encrypted/decrypted via the injected `KeyVaultsGateKeeper`.
- Static (system-wide): `findByPlatformAndAppId`, `findEnabledByPlatform` — used by webhook routing & gateway sync, since they don't have a user context yet.
**TRPC router** (`src/server/routers/lambda/agentBotProvider.ts`):
| Procedure | Notes | |
| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------ |
| `listPlatforms` | Returns `SerializedPlatformDefinition[]` (no `clientFactory`) | |
| `create` / `update` / `delete` | Calls `BotMessageRouter.invalidateBot` + `GatewayService.stopClient` so changes take effect | |
| `list` / `getByAgentId` / `getRuntimeStatus` | Decorate rows with Redis runtime status | |
| `connectBot` | Returns \`{ status: 'started' | 'queued' }\` |
| `testConnection` | Calls `clientFactory.validateCredentials` | |
| `wechatGetQrCode` / `wechatPollQrStatus` | iLink onboarding flow | |
Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/agent/slices/bot/action.ts`. UI: `src/routes/(main)/agent/channel/{list,detail}` — settings form is auto-generated from each platform's `schema`.
## Reply Templates
`src/server/services/bot/replyTemplate.ts` exports `renderStart`, `renderStepProgress`, `renderFinalReply`, `renderError`, `renderStopped`, `splitMessage`. Step progress carries elapsed time, last LLM content, last tools, totals; final reply uses `client.formatMarkdown` then `client.formatReply` (which optionally appends `formatUsageStats`). `splitMessage(text, charLimit)` chunks at paragraph → line → hard cut.
`src/server/services/bot/ackPhrases/` provides randomized ack phrases.
## Key Files
```plaintext
Webhook routes:
src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts — inbound catch-all
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts — qstash bot callback
src/app/(backend)/api/agent/gateway/route.ts — cron gateway (10min window)
src/app/(backend)/api/agent/gateway/start/route.ts — non-Vercel ensureRunning
Bot service:
src/server/services/bot/index.ts — barrel
src/server/services/bot/BotMessageRouter.ts — lazy bot loading + handler registration + commands
src/server/services/bot/AgentBridgeService.ts — Chat SDK ↔ AiAgentService bridge, both exec modes
src/server/services/bot/BotCallbackService.ts — qstash callback handler
src/server/services/bot/formatPrompt.ts — speaker tag + referenced_message + sanitize
src/server/services/bot/replyTemplate.ts — render*/splitMessage
src/server/services/bot/ackPhrases/ — randomized acks
src/server/services/bot/__tests__/ — unit tests for the above
Platform abstraction:
src/server/services/bot/platforms/index.ts — registry singleton + exports
src/server/services/bot/platforms/types.ts — PlatformClient/Definition/FieldSchema/ClientFactory
src/server/services/bot/platforms/registry.ts — PlatformRegistry class
src/server/services/bot/platforms/utils.ts — mergeWithDefaults, getEffectiveConnectionMode, formatUsageStats, runtimeKey
src/server/services/bot/platforms/const.ts — shared FieldSchema fragments (displayToolCalls, serverId, userId)
src/server/services/bot/platforms/stripMarkdown.ts — used by no-markdown platforms
Per-platform (each ships definition.ts, schema.ts, client.ts, const.ts, protocol-spec.md):
src/server/services/bot/platforms/discord/ — websocket gateway + chat patches
src/server/services/bot/platforms/slack/ — multi-mode (Socket Mode / webhook), markdownToMrkdwn
src/server/services/bot/platforms/telegram/ — webhook, markdownToHTML, registerBotCommands
src/server/services/bot/platforms/feishu/ — feishu + lark share client/schema (definitions/{feishu,lark,shared}.ts)
src/server/services/bot/platforms/qq/ — websocket, no markdown, no edit
src/server/services/bot/platforms/wechat/ — long-poll, no markdown, no edit
Gateway:
src/server/services/gateway/index.ts — GatewayService (Vercel-aware startClient/stopClient)
src/server/services/gateway/GatewayManager.ts — long-running client registry (non-Vercel)
src/server/services/gateway/botConnectQueue.ts — Redis hash queue with TTL
src/server/services/gateway/runtimeStatus.ts — Redis bot:runtime-status keys
Database:
packages/database/src/schemas/agentBotProvider.ts — agent_bot_providers table
packages/database/src/models/agentBotProvider.ts — encrypted CRUD + system-wide finders
TRPC + client:
src/server/routers/lambda/agentBotProvider.ts — TRPC router
src/services/agentBotProvider.ts — client wrapper
src/store/agent/slices/bot/action.ts — Zustand actions
UI:
src/routes/(main)/agent/channel/list.tsx — channel list
src/routes/(main)/agent/channel/detail/ — auto-generated form (Header/Body/Footer)
src/routes/(main)/agent/channel/const.ts — platform icons
Types & runtime status:
src/types/botRuntimeStatus.ts — BOT_RUNTIME_STATUSES enum + snapshot type
```
## Adding a New Platform
1. Create `src/server/services/bot/platforms/<id>/`:
- `definition.ts``PlatformDefinition` registered in `platforms/index.ts`
- `schema.ts``FieldSchema[]` (`applicationId` + `credentials` + `settings`); reuse fragments from `../const.ts`
- `client.ts``class XClientFactory extends ClientFactory` returning a `PlatformClient` (lifecycle + adapter + messenger + helpers)
- `const.ts``DEFAULT_X_CONNECTION_MODE`, history limits, etc.
- `protocol-spec.md` — protocol notes (every existing platform has one)
2. Pick the right `connectionMode` — webhook is much simpler if the platform supports it.
3. If the platform can't render markdown, set `supportsMarkdown: false` and implement `formatMarkdown` via `stripMarkdown`.
4. If it can't edit messages, set `supportsMessageEdit: false``BotCallbackService` will skip step edits and only send the final reply.
5. Implement `validateCredentials` so the UI's "Test connection" button gives useful errors.
6. Add the platform icon in `src/routes/(main)/agent/channel/const.ts` and register the platform in `src/server/services/bot/platforms/index.ts`.
7. Add i18n keys under `channel.*` in `src/locales/default/setting.ts` (or wherever the channel namespace lives) — the schema's `label`/`description`/`placeholder`/`enumLabels` are i18n keys.
+130
View File
@@ -0,0 +1,130 @@
---
name: builtin-tool
description: Build a new builtin tool package under `packages/builtin-tool-<name>/`. Use when adding a new agent-callable toolset, designing its API surface (manifest / ApiName / Params / State), implementing the Executor + ExecutionRuntime, building the Inspector / Render / Placeholder / Streaming / Intervention / Portal UI, or wiring a tool into the central registries (`packages/builtin-tools/src/{index,identifiers,inspectors,renders,placeholders,streamings,interventions,portals}.ts` and `src/store/tool/slices/builtin/executors/index.ts`). Triggers on "new builtin tool", "add a tool", "tool inspector", "tool render", "tool placeholder", "tool streaming", "tool intervention", "BuiltinToolManifest", "BaseExecutor", "ExecutionRuntime".
---
# Builtin Tool Authoring Guide
A builtin tool is a package the agent runtime can call. It ships **five faces**:
| Face | Lives in | Audience |
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------- |
| **Manifest + types** | `src/{manifest,types,systemRole}.ts` | The LLM (tool spec + system prompt) |
| **ExecutionRuntime** | `src/ExecutionRuntime/` | Server / desktop / any runtime caller |
| **Executor** | `src/client/executor/` | Frontend (wraps stores/services) |
| **Client UI** | `src/client/{Inspector,Render,…}/` | Chat UI |
| **Registry wiring** | `packages/builtin-tools/src/*.ts` + `src/store/tool/slices/builtin/executors/index.ts` | Framework |
---
## Read These First
| Question | Doc |
| ------------------------------------------------------------------------------------ | --------------------------------------------- |
| Where do files live? What does each face do? Wiring? | [architecture.md](references/architecture.md) |
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](references/tool-design.md) |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](references/ui.md) |
---
## When to Use This Skill
- Creating a new `packages/builtin-tool-<name>/` package
- Adding a new API method to an existing builtin tool
- Building or restyling any of the 6 client surfaces for a tool
- Wiring a tool into the central registries
- Debugging "tool not found / API not found / render not showing / placeholder stuck" errors
---
## Top-Level Design Principles
1. **`lobe-<domain>` identifier is permanent.** It's stored in message history. Renames need `@deprecated` aliases (see `packages/builtin-tools/src/inspectors.ts:88-89`). Get it right the first time.
2. **ApiName is an `as const` object**, not a TS enum. It doubles as the runtime list `BaseExecutor` iterates over.
3. **Three result fields, three audiences:**
- `content: string` → the LLM reads it
- `state: Record<…>` → the UI's `pluginState`; **result-domain only**, never echo all params back
- `error: { type, message, body? }` → both LLM and UI; `type` is a stable code
4. **Split execution from frontend wiring.**
- `src/ExecutionRuntime/` — pure runtime, no React, no Zustand, accepts services via constructor. **The default place for new logic.**
- `src/client/executor/``BaseExecutor` subclass that calls `ExecutionRuntime` (or stores/services directly when frontend-only).
5. **UI defaults to "do nothing".** Inspector is required (the header strip). Render/Placeholder/Streaming/Intervention/Portal are added **only when there's something specific to show** — empty registries are fine.
6. **Style with `createStaticStyles + cssVar.*`** (zero-runtime). Fall back to `createStyles + token` only when you genuinely need runtime values. Use `@lobehub/ui` components, not raw antd.
7. **i18n keys live in `src/locales/default/plugin.ts`.** Inspector titles must come from `t('builtins.<identifier>.apiName.<api>')` so something renders while args stream.
---
## Package Layout (preferred, post-2026 convention)
```
packages/builtin-tool-<name>/
├── package.json
└── src/
├── index.ts # exports manifest + types + systemRole + Identifier (no React, no stores)
├── manifest.ts # BuiltinToolManifest with JSON Schema for every API
├── types.ts # ApiName const + Params/State interfaces per API
├── systemRole.ts # System prompt teaching the model when/how to use the APIs
├── ExecutionRuntime/ # ✅ Default home for runtime logic (server- or anywhere-callable)
│ └── index.ts
└── client/
├── index.ts # Re-exports for the registries
├── executor/ # ✅ Frontend executor — extends BaseExecutor, often delegates to ExecutionRuntime
│ └── index.ts
├── Inspector/ # required — header chip per API
├── Render/ # optional — rich result card
├── Placeholder/ # optional — skeleton during streaming/execution
├── Streaming/ # optional — live output renderer (e.g. RunCommand, WriteFile)
├── Intervention/ # optional — approval / edit-before-run UI
├── Portal/ # optional — full-screen detail view
└── components/ # shared subcomponents used by the surfaces above
```
**Older packages** (`builtin-tool-task`, `builtin-tool-calculator`, etc.) still have `src/executor/` as a sibling of `src/client/`. That's grandfathered; **don't relocate without a deliberate refactor**. New packages and new APIs added to existing packages should follow the layout above.
`package.json` exports map:
```json
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/client/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
}
```
---
## Authoring Checklist
Before opening the PR:
- [ ] Identifier follows `lobe-<domain>` and is **stable** (lives in message history).
- [ ] Every `<Name>ApiName` value has: a manifest `api[]` entry, an executor method, an Inspector, an i18n `apiName.*` key.
- [ ] `Params` interfaces match the JSON Schema; `State` interfaces match what the executor returns and what the UI surfaces read.
- [ ] System prompt disambiguates confusable APIs and points to batch variants.
- [ ] Runtime logic lives in `ExecutionRuntime/`; the `client/executor/` only wires stores/services and delegates.
- [ ] Executor returns `{ success, content, state, error? }` via a single `toResult()` funnel — `content` always non-empty (default to `error.message`).
- [ ] Inspector handles `isArgumentsStreaming`, `isLoading`, `partialArgs`, missing `pluginState`.
- [ ] Render returns `null` until it has data; only created for APIs with rich results.
- [ ] Placeholder added if the API has a perceivable execution lag (search, list, crawl).
- [ ] Streaming added for APIs that emit incremental output (run command, write file, code execution).
- [ ] Intervention added if `humanIntervention` is set in the manifest.
- [ ] All registry files updated (see [architecture.md → Registry wiring](references/architecture.md#registry-wiring)).
- [ ] i18n keys in `src/locales/default/plugin.ts` plus dev seeds in `en-US`/`zh-CN`.
- [ ] `bunx vitest run --silent='passed-only' 'packages/builtin-tool-<name>'` passes.
- [ ] `bun run type-check` passes.
---
## Reference Tools
Pick the closest neighbor and copy:
| If your tool is… | Read first |
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Pure-compute, no UI state | `packages/builtin-tool-calculator/``ExecutionRuntime` reuses executor (mathjs/nerdamer work everywhere) |
| CRUD over a domain entity | `packages/builtin-tool-task/` — full Inspector + Render set, batch variants |
| Heavy UI (Inspector/Render/Placeholder/Portal) | `packages/builtin-tool-web-browsing/` — search-style result UI, Portal for detail view |
| Desktop / filesystem with all surfaces (incl. Streaming + Intervention) | `packages/builtin-tool-local-system/``ExecutionRuntime` injects an `ILocalSystemService`, executor calls it |
| Server-side pure (no client executor) | `packages/builtin-tool-web-browsing/` — only `ExecutionRuntime` is exported; the chat client doesn't run it |
| Needs human approval before running | `packages/builtin-tool-local-system/src/client/Intervention/` — per-API approval components |
@@ -0,0 +1,315 @@
# Builtin Tool Architecture
## The Five Faces
A builtin tool ships five distinct faces, each compiled into a different bundle:
```
┌─────────────────────────────────────────────────────────────────┐
│ ./ │
│ Manifest + Types + systemRole │
│ ─ Pure data, no React, no Node-only deps. │
│ ─ Imported by: server (LLM tool spec), client (registries), │
│ anyone who needs to know "what tools exist". │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ./executionRuntime │
│ src/ExecutionRuntime/index.ts │
│ ─ Pure runtime logic. Accepts services via constructor — │
│ never imports concrete services or stores directly. │
│ ─ Imported by: server (BuiltinServerRuntimeOutput), tests, │
│ and the client executor as a delegate. │
│ ─ Returns: BuiltinServerRuntimeOutput { content, state, … } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ./executor │
│ src/client/executor/index.ts │
│ ─ BaseExecutor subclass. Wires Zustand stores and frontend │
│ services into ExecutionRuntime, then funnels through │
│ toResult() into BuiltinToolResult { content, state, error, │
│ success }. │
│ ─ Imported by: src/store/tool/slices/builtin/executors/ │
│ index.ts (registered as a singleton). │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ./client │
│ src/client/{Inspector,Render,Placeholder,Streaming, │
│ Intervention,Portal,components}/ │
│ ─ React 'use client' surfaces. Read args + pluginState. │
│ ─ Imported by: packages/builtin-tools/src/{inspectors, │
│ renders,placeholders,streamings,interventions,portals}.ts. │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Registry wiring │
│ packages/builtin-tools/src/*.ts │
│ src/store/tool/slices/builtin/executors/index.ts │
│ ─ Aggregator maps: identifier → { apiName → component }. │
└─────────────────────────────────────────────────────────────────┘
```
The split exists so:
- Server bundles import only `./` and `./executionRuntime` and never touch React.
- Frontend bundles import `./client` and never touch Node-only services.
- The runtime is testable without React or Electron present.
---
## Why ExecutionRuntime is the Default Home for Logic
**Old pattern (grandfathered):** business logic in `src/executor/` directly. Examples: `builtin-tool-task`, older tools. Works, but the executor mixes runtime logic with frontend service plumbing — hard to reuse on the server.
**New pattern (preferred):** business logic in `src/ExecutionRuntime/`, frontend wiring in `src/client/executor/`. Examples: `builtin-tool-local-system`, `builtin-tool-web-browsing`, `builtin-tool-calculator`.
```
ExecutionRuntime
├─ accepts services via constructor (or `static create(opts)`)
├─ returns BuiltinServerRuntimeOutput (content + state + success)
└─ no React, no Zustand, no `@/services/...` direct imports
client/executor
├─ extends BaseExecutor<typeof <Name>ApiName>
├─ holds a `runtime = new <Name>ExecutionRuntime(realService)` instance
├─ each ApiName method:
│ 1. resolve scope / pull defaults from BuiltinToolContext
│ 2. call runtime.<method>(args)
│ 3. funnel through toResult() → BuiltinToolResult
└─ exported singleton: export const <name>Executor = new <Name>Executor()
```
### Service injection
`ExecutionRuntime` should declare a TypeScript interface for the services it needs and accept the implementation via constructor. Server callers wire in real implementations; tests wire in mocks. Example from `local-system`:
```ts
export interface ILocalSystemService {
readLocalFile: (params: any) => Promise<any>;
writeFile: (params: any) => Promise<any>;
/* … */
}
export class LocalSystemExecutionRuntime extends ComputerRuntime {
constructor(private service: ILocalSystemService) {
super();
}
/* methods delegate to this.service.* */
}
```
The `client/executor` instantiates it once with the real service:
```ts
import { localFileService } from '@/services/electron/localFileService';
import { LocalSystemExecutionRuntime } from '../../ExecutionRuntime';
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
private runtime = new LocalSystemExecutionRuntime(localFileService);
/* … */
}
```
### When ExecutionRuntime is the only thing you ship
Some tools are server-only — there's no frontend executor. `builtin-tool-web-browsing` is the canonical example: only `./` and `./executionRuntime` are exported, no `./executor`, and the runtime is constructed by the server-side `ToolExecutionService`. Skip `client/executor/` entirely for those.
### When the executor reuses the runtime as-is
Pure-compute tools (`builtin-tool-calculator`) often have an executor whose ApiName methods call `executor.calculate(args)` and an `ExecutionRuntime` whose methods call `calculatorExecutor.calculate(args)` — same logic, two thin wrappers. That's fine; the duplication buys you the bundle split.
---
## The Result Contract
### `BuiltinServerRuntimeOutput` (what ExecutionRuntime returns)
```ts
{
content: string; // the LLM-facing text — never undefined; default to error message
state?: any; // result-domain object the UI reads as pluginState
success: boolean; // mandatory
error?: any; // raw error; the executor will repackage
}
```
### `BuiltinToolResult` (what the executor returns to the runtime)
```ts
{
success: boolean;
content?: string;
state?: any;
error?: { type: string; message: string; body?: any };
metadata?: Record<string, any>; // rare; e.g. { agentCouncil: true }
stop?: boolean; // rare; halt the orchestration step
}
```
### The `toResult` funnel (mandatory)
Every executor method returns through a single `toResult()` to enforce two invariants:
1. **`content` is never undefined.** A missing content collapses downstream into `''`, leaving the Debug pane blank while `pluginState` was already saved. See the `globLocalFiles` regression in `local-system/src/client/executor/index.ts:60-84`.
2. **`state` survives failures.** Renderers can keep showing partial output even when `success: false`.
```ts
private toResult(output: BuiltinServerRuntimeOutput): BuiltinToolResult {
const errorMessage = typeof output.error?.message === 'string' ? output.error.message : undefined;
const safeContent = output.content || errorMessage || 'Tool execution failed';
if (!output.success) {
return {
success: false,
content: safeContent,
state: output.state,
error: output.error
? { type: 'PluginServerError', message: errorMessage ?? safeContent, body: output.error }
: undefined,
};
}
return { success: true, content: safeContent, state: output.state };
}
```
---
## `BaseExecutor` — How Method Dispatch Works
`BaseExecutor.invoke(apiName, params, ctx)` does:
```ts
if (!this.hasApi(apiName)) return { error: { type: 'ApiNotFound', }, success: false };
return (this as any)[apiName](params, ctx); // method name MUST equal apiName value
```
So:
- **Method names must equal `<Name>ApiName` values, exactly.** A typo silently routes to "ApiNotFound".
- **Methods must be class fields, not class methods**, because `this` is lost when registry calls `executor.invoke(apiName, params, ctx)`. Always declare as `methodName = async (…) => { … }`.
- **Always destructure `apiEnum` and `identifier` as `readonly` instance fields**, not getters — `BaseExecutor.hasApi/getApiNames` reads them synchronously.
---
## `BuiltinToolContext` — What the Executor Receives
The runtime hands every executor method an optional `BuiltinToolContext` as the second argument:
| Field | Use |
| ----------------------------- | -------------------------------------------------------------- |
| `agentId` | Default agent for "current agent" semantics (e.g. `listTasks`) |
| `groupId` | Group chat scope |
| `topicId` | Current topic — needed when creating messages/operations |
| `taskId` | Current task identifier — fallback for "implicit" param |
| `documentId` | Current page/document scope |
| `messageId` | The tool message being created (for state attachments) |
| `sourceMessageId` | The user message that triggered this tool turn |
| `operationId` | Operation lineage (use for cancellation, tracing) |
| `scope` | `'task' \| 'agent' \| …` — toggles default behaviors |
| `signal: AbortSignal` | Honor for long-running ops |
| `stepContext` | Cross-message runtime state (lobe-agent todos, etc.) |
| `registerAfterCompletion(cb)` | Defer side-effects past message-update race |
| `groupOrchestration` | Group orchestration callbacks |
**Use rule:** read with `?.`, fall back to explicit params, **never silently override** an explicit param with a context value.
---
## i18n Integration
Source of truth: `src/locales/default/plugin.ts`. Keys follow `builtins.<identifier>.<topic>.<…>`:
| Key | Use |
| ------------------------------------- | ------------------------------------------------------------ |
| `builtins.<identifier>.title` | Display title (overrides `manifest.meta.title` when present) |
| `builtins.<identifier>.apiName.<api>` | Inspector header label (one per ApiName) |
| `builtins.<identifier>.inspector.<…>` | Extra Inspector strings ("no results", chips, counters) |
| `builtins.<identifier>.<feature>.<…>` | Render / Intervention strings, free-form per tool |
For dev preview, also seed `locales/zh-CN/plugin.json` and `locales/en-US/plugin.json`. Run `pnpm i18n` before opening a PR — it's slow, so do it once at the end. (See the **i18n** skill for the full workflow.)
---
## Registry Wiring
Five core files plus optional ones. Miss any and you'll see "tool not found", a missing chip, a blank result card, a stuck spinner, or an approval dialog that never appears.
| File | Add what |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| **Required** | |
| `packages/builtin-tools/src/index.ts` | Import `<Name>Manifest`; push entry to `builtinTools`. Set `hidden`/`discoverable` flags. |
| `packages/builtin-tools/src/identifiers.ts` | Add `<Name>Manifest.identifier` to `builtinToolIdentifiers`. |
| `packages/builtin-tools/src/inspectors.ts` | Import `<Name>Inspectors, <Name>Manifest`; add to `BuiltinToolInspectors`. |
| `src/store/tool/slices/builtin/executors/index.ts` | Import `<name>Executor`; add to `registerExecutors([…])`. |
| **Conditional — add only if the surface exists** | |
| `packages/builtin-tools/src/renders.ts` | Add to `BuiltinToolsRenders` if any API has a Render. |
| `packages/builtin-tools/src/placeholders.ts` | Add to `BuiltinToolPlaceholders` if any API has a Placeholder. |
| `packages/builtin-tools/src/streamings.ts` | Add to `BuiltinToolStreamings` if any API has a Streaming renderer. |
| `packages/builtin-tools/src/interventions.ts` | Add to `BuiltinToolInterventions` if any API has an Intervention component. |
| `packages/builtin-tools/src/portals.ts` | Add to `BuiltinToolsPortals` if the tool has a Portal. |
| `packages/builtin-tools/src/displayControls.ts` | Add if Render must show/hide based on result content (rare; see ClaudeCode/Codex). |
### Optional flags in `packages/builtin-tools/src/index.ts`
```ts
{
identifier: TaskManifest.identifier,
manifest: TaskManifest,
type: 'builtin',
hidden: true, // hide from chat-input Tools popover
discoverable: false, // exclude from agent builder / skill discovery
}
```
Lists in the same file you may need to touch:
- `defaultToolIds` — added to the agent's tool list by default
- `alwaysOnToolIds` — forced on regardless of user selection (use sparingly)
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `src/server/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
---
## File-Map at a Glance
```
packages/builtin-tool-<name>/
├── package.json # exports: ., ./client, ./executor, ./executionRuntime
└── src/
├── index.ts # export Manifest, Identifier, types, systemPrompt
├── manifest.ts # BuiltinToolManifest + Identifier const
├── types.ts # ApiName + Params/State per API
├── systemRole.ts # System prompt (multiple variants OK: systemRole.desktop.ts)
├── ExecutionRuntime/
│ └── index.ts # <Name>ExecutionRuntime — pure runtime, service injection
└── client/
├── index.ts # exports for the registries
├── executor/
│ └── index.ts # <Name>Executor extends BaseExecutor; export <name>Executor
├── Inspector/
│ ├── index.ts # <Name>Inspectors record
│ └── <ApiName>/index.tsx # one folder per API (or .tsx file when trivial)
├── Render/
│ ├── index.ts # <Name>Renders record
│ └── <ApiName>/ # rich renders → folder with subcomponents
├── Placeholder/
│ ├── index.ts
│ └── <ApiName>.tsx # usually a single skeleton file
├── Streaming/
│ ├── index.ts
│ └── <ApiName>/ # live-output renderer
├── Intervention/
│ ├── index.ts
│ └── <ApiName>/ # approval / edit-before-run UI
├── Portal/
│ ├── index.tsx # routing component (switch on apiName)
│ └── <ApiName>/ # full-screen detail view
└── components/ # FileItem, EngineAvatar, etc. — shared subcomponents
```
Skip every `client/<surface>/` directory you don't need — empty registries are fine.
@@ -0,0 +1,478 @@
# Tool Design (Naming, Manifest, Executor, Runtime)
This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md).
For where files live and how registries work, see [architecture.md](architecture.md).
---
## 1. Naming
| Thing | Convention | Example |
| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
| Package directory | `packages/builtin-tool-<kebab>/` | `builtin-tool-task` |
| npm name | `@lobechat/builtin-tool-<kebab>` | `@lobechat/builtin-tool-task` |
| Tool `identifier` | `lobe-<kebab-domain>`**persisted in message history** | `lobe-task`, `lobe-calculator`, `lobe-knowledge-base` |
| Identifier const | `<Name>Identifier` exported from `manifest.ts` (or `types.ts`) | `export const TaskIdentifier = 'lobe-task'` |
| API name const | `<Name>ApiName``as const` object, **camelCase verbs** | `createTask`, `listTasks`, `runTask` |
| Executor class | `<Name>Executor extends BaseExecutor<typeof <Name>ApiName>` | `TaskExecutor` |
| Executor singleton | `<name>Executor` (camelCase) | `export const taskExecutor = new TaskExecutor()` |
| ExecutionRuntime class | `<Name>ExecutionRuntime` | `LocalSystemExecutionRuntime`, `WebBrowsingExecutionRuntime` |
| Inspector / Render etc. | `<ApiName>Inspector` / `<ApiName>Render` | `CreateTaskInspector`, `SearchInspector` |
### Identifier rules
- **`lobe-` prefix is mandatory** — many switches in the codebase key off it.
- Pick a **domain noun**, not a verb (`lobe-task`, not `lobe-task-manager`).
- The identifier is **persisted in message history** — renaming after release means the `@deprecated` alias trick (register the legacy identifier as a second key in `inspectors.ts` / `renders.ts` pointing at the new module). Get it right the first time.
### ApiName rules
- Verb + noun, camelCase: `createTask`, `viewTask`, `runTasks`.
- **Plural variant for batch** (`createTasks`, `runTasks`) — describe in the manifest description that it's preferred over multiple single calls. The system prompt should also push the batch form.
- Reserve **clear separation between mutating verbs** (`updateTaskStatus`, `editTask`) and **execution verbs** (`runTask`). The system prompt must warn the model when these are confusable — see `task` for the canonical "do NOT use updateTaskStatus(running) to start a task" warning.
- Read-only verbs: `list*`, `view*`, `get*`, `search*`. Mutating: `create*`, `edit*`, `update*`, `delete*`. Triggers/effects: `run*`, `execute*`, `submit*`.
---
## 2. `types.ts` — ApiName + Params/State
Define `<Name>ApiName` as `as const` so it doubles as a runtime enum (used by `BaseExecutor`) and a literal type. Then declare `Params` and `State` per API.
```ts
export const TaskIdentifier = 'lobe-task';
export const TaskApiName = {
createTask: 'createTask',
createTasks: 'createTasks',
listTasks: 'listTasks',
/* …one entry per API, group logically (CRUD then run-style) */
} as const;
export type TaskApiNameType = (typeof TaskApiName)[keyof typeof TaskApiName];
// One block per API
export interface CreateTaskParams {
name: string;
instruction: string; /* … */
}
export interface CreateTaskState {
identifier?: string;
success: boolean;
}
export interface CreateTasksParams {
tasks: CreateTaskParams[];
}
export interface CreateTasksItemResult {
error?: string;
identifier?: string;
name: string;
success: boolean;
}
export interface CreateTasksState {
failed: number;
results: CreateTasksItemResult[];
succeeded: number;
}
```
**The result-domain rule for `State`** (memory: "pluginState is result-domain, not call-domain"):
- Include only fields the UI **renders after the call returns** — ids the LLM didn't have when calling, counts, summary numbers, server-assigned status.
- **Don't echo all params.** The Inspector/Render gets `args` for free.
- Keep batch results as `{ succeeded, failed, results }` so the Render can show a one-line summary plus a detail list.
---
## 3. `manifest.ts` — JSON Schema for the LLM
```ts
import type { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { TaskApiName, TaskIdentifier } from './types';
export const TaskManifest: BuiltinToolManifest = {
identifier: TaskIdentifier,
type: 'builtin',
systemRole: systemPrompt,
meta: {
avatar: '📋',
title: 'Task Tools',
description: 'Create, list, edit, delete tasks with dependencies',
readme: 'Optional long description shown in tool detail pages',
},
api: [
{
name: TaskApiName.createTask,
description:
'Create a new task. Optionally attach as a subtask via parentIdentifier. ' +
'Prefer createTasks when planning a batch.',
parameters: {
type: 'object',
required: ['name', 'instruction'],
properties: {
name: { type: 'string', description: 'Short, descriptive name.' },
instruction: {
type: 'string',
description: 'Detailed instruction for what the task should accomplish.',
},
parentIdentifier: {
type: 'string',
description:
'Identifier of the parent task (e.g. "TASK-1"). If provided, the new task becomes a subtask.',
},
priority: {
type: 'number',
description: 'Priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low. Default is 0.',
},
},
},
},
/* …one entry per ApiName */
],
};
```
### Manifest writing checklist
- **Every API in `<Name>ApiName` has exactly one entry in `api[]`.** Easy to drift after a refactor.
- **`description` on each API is the model's only docs.** Make it long enough for the LLM to pick the right tool. Mention edge cases ("If you provide any filter, omitted filters are not applied implicitly"), defaults, and the relationship to sibling APIs ("To START a task, use runTask — updateTaskStatus only flips a flag").
- **`parameters` is JSON Schema** (`LobeChatPluginApi`). Use `enum`, `required`, `items`, `oneOf`, `additionalProperties: false` etc. — these survive into the LLM's tool spec.
- **Use `additionalProperties: false`** on parameter objects so the model can't sneak unknown fields past validation.
- **Number parameters with semantic values** (`priority: 0=none, 1=urgent, …`) should describe the mapping in the description. Don't rely on `enum` alone for numbers — the model often fills the wrong one.
- **`enum` arrays for known string sets** (statuses, categories, engines). Spread from a constants module (`enum: [...TASK_STATUSES]`) so the manifest stays in sync.
### Optional manifest fields
```ts
{
/* Where this tool can run.
'client' → Agent Gateway dispatches to the desktop client (filesystem, Electron only)
'server' → ToolExecutionService runs it on the server
omitted → server only */
executors: ['client', 'server'],
/* Default human intervention policy for all APIs that don't specify one.
Pair with an Intervention component (see ui.md). */
humanIntervention: 'never' | 'always' | { /* extended config */ },
}
```
Per-API `humanIntervention` and `renderDisplayControl` go inside each `api[]` entry.
---
## 4. `systemRole.ts` — Operator Instructions for the Model
This is appended to the agent system prompt whenever the tool is enabled. Treat it as a **how-to-use guide for the LLM**, not marketing copy.
```ts
export const systemPrompt = `You have access to Task management tools. Use them to:
- **createTask**: Create a new task. Use parentIdentifier to make it a subtask.
- **createTasks**: Prefer this over multiple createTask calls when planning a batch
(e.g. all subtasks under one parent, or all chapters of an outline).
- **runTask**: Actually START a task — kicks off the agent in a new (or continued)
topic. Do NOT use updateTaskStatus(running) to start a task; that only flips a
flag without executing. The task must have an assigneeAgentId.
- **updateTaskStatus**: Change a task's status (completed/cancelled/paused/failed).
If you mark a task as failed, include an error message explaining why.
- ...
When planning work:
1. Create tasks for each major piece (use parentIdentifier to organize as subtasks).
2. Use editTask with addDependencies to control execution order.
3. Use updateTaskStatus to mark the current task completed when done.`;
```
### Patterns that work well
- **Bulleted list, bold the API name, one line per API.** The model picks tools by skimming.
- **Disambiguate confusable APIs explicitly** (`runTask` vs `updateTaskStatus`).
- **Push toward batched APIs** ("Prefer this when…").
- **End with a numbered workflow** if the tool has a typical sequence.
- **For tools with multiple environments** (e.g. desktop vs cloud), keep variants in `systemRole.ts` and `systemRole.desktop.ts` and pick at the manifest level. See `builtin-tool-local-system`.
### Dynamic system prompts
If the prompt depends on runtime state (current date, available models), export a function and call it in the manifest:
```ts
// systemRole.ts
export const systemPrompt = (today: string) => `Today is ${today}. You have web search tools…`;
// manifest.ts
import dayjs from 'dayjs';
systemRole: systemPrompt(dayjs(new Date()).format('YYYY-MM-DD')),
```
---
## 5. `ExecutionRuntime/index.ts` — Pure Runtime
This is **the default home for new tool logic** going forward. The runtime is a class that:
- Has no React, no Zustand, no `@/services/...` direct imports.
- Receives services as **constructor injection** (or as method args).
- Returns `BuiltinServerRuntimeOutput` from each method.
- Is unit-testable by passing in mocks.
### Pattern A: Inject a service interface
Use when the runtime calls out to IPC, network, or DB.
```ts
// ExecutionRuntime/index.ts
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
export interface IWebBrowsingService {
search: (q: SearchQuery) => Promise<UniformSearchResponse>;
crawlPages: (urls: string[]) => Promise<CrawlResults>;
}
export interface WebBrowsingRuntimeOptions {
searchService: IWebBrowsingService;
documentService?: WebBrowsingDocumentService;
agentId?: string;
topicId?: string;
}
export class WebBrowsingExecutionRuntime {
constructor(private opts: WebBrowsingRuntimeOptions) {}
async search(
args: SearchQuery,
options?: { signal?: AbortSignal },
): Promise<BuiltinServerRuntimeOutput> {
try {
const data = await this.opts.searchService.search(args, options);
if (data.errorDetail) {
return {
success: false,
content: data.errorDetail,
error: { message: data.errorDetail },
state: data,
};
}
return {
success: true,
content: searchResultsPrompt(data.results.slice(0, 10)),
state: data,
};
} catch (e) {
return { success: false, content: (e as Error).message, error: e };
}
}
}
```
### Pattern B: Reuse the executor
Use when the same logic runs in browser and Node (e.g. mathjs, nerdamer). The runtime is a thin wrapper that imports the executor and re-types the state per API. See `builtin-tool-calculator/src/ExecutionRuntime/index.ts` for the canonical example.
### Pattern C: Extend a shared base
When you're implementing a domain that already has a base runtime (file ops via `ComputerRuntime`), extend and only override `callService` + result normalization. See `builtin-tool-local-system/src/ExecutionRuntime/index.ts`.
### Runtime contract
Every method returns:
```ts
{
content: string; // LLM-facing — never undefined; default to error message
state?: any; // result-domain — what the UI's pluginState becomes
success: boolean; // mandatory
error?: any; // raw error object; the executor will repackage
}
```
Use `@lobechat/prompts` formatters (`searchResultsPrompt`, `crawlResultsPrompt`, `formatTaskCreated`, etc.) to produce structured `content`. They emit XML/markdown that's already tuned for token efficiency.
---
## 6. `client/executor/index.ts` — Frontend Wiring
The executor's job is to **resolve frontend defaults** (current agent, current task, scope) and **call the runtime**. It then funnels through `toResult()` into the `BuiltinToolResult` shape.
```ts
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
import debug from 'debug';
import { taskService } from '@/services/task';
import { getTaskStoreState } from '@/store/task';
import { TaskIdentifier } from '../../manifest';
import { TaskApiName, type CreateTaskParams } from '../../types';
const log = debug('lobe-task:executor');
class TaskExecutor extends BaseExecutor<typeof TaskApiName> {
readonly identifier = TaskIdentifier;
protected readonly apiEnum = TaskApiName;
// ⚠ class FIELD, not a method — preserves `this` when invoked via registry
createTask = async (
params: CreateTaskParams,
ctx?: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
log('createTask params=%o', params);
const task = await getTaskStoreState().createTask({
name: params.name,
instruction: params.instruction,
// Default assignee from context — never silently override an explicit value
assigneeAgentId:
params.assigneeAgentId ?? (ctx?.scope === 'task' ? undefined : ctx?.agentId),
parentTaskId: params.parentIdentifier?.trim() || undefined,
priority: params.priority,
});
if (!task) return this.errorResult('Failed to create task', 'CreateFailed');
return {
success: true,
content: formatTaskCreated({ identifier: task.identifier, name: task.name /* … */ }),
state: { identifier: task.identifier, success: true },
};
} catch (error) {
return this.errorResult(error, 'CreateTaskFailed');
}
};
private errorResult(err: unknown, type: string): BuiltinToolResult {
const message = err instanceof Error ? err.message : String(err) || 'Unknown error';
return { success: false, content: `Failed: ${message}`, error: { type, message } };
}
}
export const taskExecutor = new TaskExecutor();
```
### Hard rules
1. **Methods are class fields** (`name = async (…) => {…}`), not class methods. The registry calls `(executor as any)[apiName](params, ctx)`; arrow-function fields keep `this` bound.
2. **`identifier` and `apiEnum` are `readonly` instance fields**, not getters — `BaseExecutor.hasApi/getApiNames` reads them synchronously at registration time.
3. **Default missing params from `ctx`**, but never silently override explicit values. Use `params.foo ?? ctx?.foo`, not `ctx?.foo ?? params.foo`.
4. **One funnel for all returns.** Either always return through `toResult(runtime.x())` (when delegating) or through `errorResult(…)` for the catch arm. Never inline `{ success: false, content: '' }``content: ''` collapses the Debug pane to blank.
5. **`debug('lobe-<name>:executor')`.** Match the namespace to the identifier minus `lobe-` when convenient.
6. **Singleton export.** `export const <name>Executor = new <Name>Executor()` — the registry imports the instance, not the class.
### When the executor delegates to ExecutionRuntime
```ts
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
readonly identifier = LocalSystemIdentifier;
protected readonly apiEnum = LocalSystemApiEnum;
private runtime = new LocalSystemExecutionRuntime(localFileService);
readLocalFile = async (params: LocalReadFileParams): Promise<BuiltinToolResult> => {
try {
const result = await this.runtime.readFile({
path: params.path,
startLine: params.loc?.[0],
endLine: params.loc?.[1],
});
return this.toResult(result);
} catch (error) {
return this.errorResult(error);
}
};
private toResult(out: BuiltinServerRuntimeOutput): BuiltinToolResult {
const errMsg = typeof out.error?.message === 'string' ? out.error.message : undefined;
const safe = out.content || errMsg || 'Tool execution failed';
if (!out.success) {
return {
success: false,
content: safe,
state: out.state, // ← preserve partial state on failure
error: out.error
? { type: 'PluginServerError', message: errMsg ?? safe, body: out.error }
: undefined,
};
}
return { success: true, content: safe, state: out.state };
}
}
```
The `toResult` funnel is **mandatory**: it enforces never-undefined `content` and partial-state preservation. Both invariants caught real production bugs (`globLocalFiles` Response empty, `editLocalFile` partial state lost).
---
## 7. `index.ts` — Package Entry Point
Keep it pure data + the manifest. **No React, no stores, no Node-only imports.**
```ts
export { TaskIdentifier, TaskManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
TaskApiName,
type TaskApiNameType,
type CreateTaskParams,
type CreateTaskState,
/* …all Params/State types */
} from './types';
// Optional helpers used by both the runtime and the UI
export { TASK_STATUSES, UNFINISHED_TASK_STATUSES } from './constants';
```
This entry is what `packages/builtin-tools/src/index.ts` and `identifiers.ts` import — it must be importable from server bundles.
---
## 8. `package.json`
```json
{
"dependencies": {
"@lobechat/prompts": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
},
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/client/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"name": "@lobechat/builtin-tool-<name>",
"peerDependencies": {
"@lobehub/ui": "^5",
"antd": "^6",
"antd-style": "*",
"lucide-react": "*",
"react": "*",
"react-i18next": "*"
},
"private": true,
"version": "1.0.0"
}
```
**Why peer not direct deps for client libs:** the `./` and `./executionRuntime` entry points must be importable from server code. Listing React etc. as peer deps prevents bundlers from following them when only the runtime is consumed.
**Skip `./executor`** if the package has no frontend executor (server-only tools like `builtin-tool-web-browsing`).
---
## 9. Common Pitfalls
| Symptom | Likely cause |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| "ApiNotFound" at runtime | Method name in executor doesn't match `ApiName` value (typo, wrong case) |
| Method works once, then "this is undefined" | Method declared as `async fn() {}` instead of `fn = async () => {}``this` lost when registry invokes |
| Debug "Response" pane blank but `pluginState` populated | Returning `content: ''` or letting `output.content` be undefined — use the `toResult` funnel |
| Partial result vanishes on failure | `toResult` discarded `state` when `success: false`; preserve it |
| Tool shows up but doesn't run on desktop | `executors` in manifest doesn't include `'client'` (or vice versa for server-only) |
| Same tool registered twice / legacy identifier ghost | Identifier collision; check `@deprecated` aliases in `inspectors.ts`/`renders.ts` |
| Manifest test fails after adding API | Forgot to add the corresponding i18n `apiName.<api>` key |
| TypeScript error on `BaseExecutor<typeof X>` | `X` declared with `enum` instead of `as const` object — must be the const-object form |
@@ -0,0 +1,721 @@
# Tool UI Surfaces
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
| Surface | Required? | When the chat shows it | Registered in |
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
The two reference tools to read end-to-end:
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
---
## 0. Shared Style Rules
These apply across every surface.
### 0.1 Use `'use client'` at the top of every component file
Tool surfaces are leaves in the chat tree and must not block server rendering.
### 0.2 Prefer `createStaticStyles + cssVar.*`
Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
```tsx
import { createStaticStyles, cssVar } from 'antd-style';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
padding-block: 2px;
padding-inline: 8px;
border-radius: 999px;
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
`,
}));
```
Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
### 0.3 Use `@lobehub/ui`, not raw `antd`
`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
Memory note: `@lobehub/ui`'s `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
### 0.4 Always `memo` and set `displayName`
```tsx
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args /* … */ }) => {
/* … */
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
### 0.5 Always type with `BuiltinXProps<Args, State>` generics
Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `<Name>Params` and `<Name>State` from `types.ts`.
### 0.6 Pull strings from `t('plugin')`
```tsx
const { t } = useTranslation('plugin');
t('builtins.<identifier>.apiName.<api>');
```
Every Inspector should default to `t('builtins.<identifier>.apiName.<api>')` so it shows something while args stream in.
### 0.7 Read store state from `@/store/chat`, not props
Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
---
## 1. Inspector — Header Chip (required)
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
### Props (`BuiltinInspectorProps<Args, State>`)
```ts
interface BuiltinInspectorProps<Arguments = any, State = any> {
apiName: string;
args: Arguments; // final args (only after the assistant stops streaming)
identifier: string;
isArgumentsStreaming?: boolean; // args still arriving
isLoading?: boolean; // args complete, executor running
partialArgs?: Arguments; // partial JSON during streaming
pluginState?: State; // executor's `state` after success
result?: { content: string | null; error?: any };
}
```
### State machine
| Phase | What's available | What to show |
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
### Canonical example — Search
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
```tsx
'use client';
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const query = args?.query || partialArgs?.query || '';
const resultCount = pluginState?.results?.length ?? 0;
const hasResults = resultCount > 0;
if (isArgumentsStreaming && !query) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-web-browsing.apiName.search')}:&nbsp;</span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
!isArgumentsStreaming &&
pluginState?.results &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
({t('builtins.lobe-web-browsing.inspector.noResults')})
</Text>
))}
</div>
);
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
### Inspector rules
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
### Inspector registry — `client/Inspector/index.ts`
```ts
import type { BuiltinInspector } from '@lobechat/types';
import { TaskApiName } from '../../types';
import { CreateTaskInspector } from './CreateTask';
import { ListTasksInspector } from './ListTasks';
/* … */
export const TaskInspectors: Record<string, BuiltinInspector> = {
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
/* one entry per ApiName */
};
export { CreateTaskInspector } from './CreateTask';
export { ListTasksInspector } from './ListTasks';
/* re-export each */
```
---
## 2. Render — Rich Result Card (optional)
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
### Props (`BuiltinRenderProps<Args, State, Content>`)
```ts
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
apiName?: string;
args: Arguments; // final params from the LLM
content: Content; // executor's content string (or parsed)
identifier?: string;
messageId: string; // for store lookups
pluginError?: any; // from BuiltinToolResult.error
pluginState?: State; // executor's state
toolCallId?: string;
}
```
### Two patterns
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
```tsx
// client/Render/CrawlSinglePage.tsx
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
import { memo } from 'react';
import PageContent from './PageContent';
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
({ messageId, pluginState, args }) => (
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
),
);
export default CrawlSinglePage;
```
**Pattern B — Folder with subcomponents** (web-browsing Search):
```
client/Render/Search/
├── index.tsx # composes the subcomponents, handles error states
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
├── SearchQuery.tsx # editable query header
└── SearchResult.tsx # result list
```
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
### Error handling in Render
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
```tsx
if (pluginError) {
if (pluginError?.type === 'PluginSettingsInvalid') {
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
}
return (
<Alert
title={pluginError?.message}
type="error"
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
/>
);
}
```
### Render rules
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
### Render registry — `client/Render/index.ts`
```ts
import type { BuiltinRender } from '@lobechat/types';
import { TaskApiName } from '../../types';
import CreateTaskRender from './CreateTask';
import RunTasksRender from './RunTasks';
export const TaskRenders: Record<string, BuiltinRender> = {
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
/* only the APIs with rich result UI — others fall back to text content */
};
export { default as CreateTaskRender } from './CreateTask';
export { default as RunTasksRender } from './RunTasks';
```
### Render display control (rare)
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
---
## 3. Placeholder — Skeleton Between Args and Result (optional)
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
### Props (`BuiltinPlaceholderProps<Args>`)
```ts
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
apiName: string;
args?: T;
identifier: string;
}
```
No `pluginState` — Placeholder lives entirely in the "executing" gap.
### Canonical example — Search Placeholder
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
```tsx
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { shinyTextStyles } from '@/styles';
const styles = createStaticStyles(({ css, cssVar }) => ({
query: cx(
css`
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
shinyTextStyles.shinyText,
),
}));
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
const { query } = args || {};
const isMobile = useIsMobile();
return (
<Flexbox gap={8}>
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
<Flexbox horizontal align="center" className={styles.query} gap={8}>
<Icon icon={SearchIcon} />
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
</Flexbox>
<Skeleton.Block active style={{ height: 20, width: 40 }} />
</Flexbox>
<Flexbox horizontal gap={12}>
{[1, 2, 3, 4, 5].map((id) => (
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
))}
</Flexbox>
</Flexbox>
);
});
```
### Placeholder rules
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
### Placeholder registry — `client/Placeholder/index.ts`
```ts
import { WebBrowsingApiName } from '../../types';
import CrawlMultiPages from './CrawlMultiPages';
import CrawlSinglePage from './CrawlSinglePage';
import { Search } from './Search';
export const WebBrowsingPlaceholders = {
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
[WebBrowsingApiName.search]: Search,
};
export { CrawlMultiPages, CrawlSinglePage, Search };
```
---
## 4. Streaming — Live Output During Execution (optional)
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
### Props (`BuiltinStreamingProps<Args>`)
```ts
interface BuiltinStreamingProps<Arguments = any> {
apiName: string;
args: Arguments;
identifier: string;
messageId: string; // use to fetch the streaming buffer from store
toolCallId: string;
}
```
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
### Canonical example — RunCommandStreaming
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
```tsx
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
interface RunCommandParams {
command?: string;
description?: string;
timeout?: number;
}
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
const { command } = args || {};
if (!command) return null;
return (
<Highlighter
animated
wrap
language="sh"
showLanguage={false}
style={{ padding: '4px 8px' }}
variant="outlined"
>
{command}
</Highlighter>
);
});
RunCommandStreaming.displayName = 'RunCommandStreaming';
```
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
```tsx
const buffer = useChatStore((state) =>
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
);
```
### Streaming rules
- Render `null` until you have something to display (avoids flash).
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
### Streaming registry — `client/Streaming/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};
```
---
## 5. Intervention — Approval / Edit-Before-Run (optional)
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
### Props (`BuiltinInterventionProps<Args>`)
```ts
interface BuiltinInterventionProps<Arguments = any> {
apiName?: string;
args: Arguments;
identifier?: string;
interactionMode?: 'approval' | 'custom';
messageId: string;
/** Called when the user edits the args; the approve action awaits this. */
onArgsChange?: (args: Arguments) => void | Promise<void>;
/** Called on approve / skip / cancel. */
onInteractionAction?: (
action:
| { type: 'submit'; payload: Record<string, unknown> }
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
| { type: 'cancel'; payload?: Record<string, unknown> },
) => Promise<void>;
/** Register a callback to flush pending saves before approval. Returns cleanup. */
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
}
```
### Canonical example — RunCommand Intervention
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
```tsx
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { memo } from 'react';
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify="space-between">
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type="secondary">
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
{command}
</Highlighter>
)}
</Flexbox>
);
});
export default RunCommand;
```
### Intervention rules
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
### Intervention registry — `client/Intervention/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import RunCommand from './RunCommand';
import WriteFile from './WriteFile';
/* … */
export const LocalSystemInterventions = {
[LocalSystemApiName.editLocalFile]: EditLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.writeLocalFile]: WriteFile,
/* one entry per API that needs approval */
};
```
---
## 6. Portal — Full-Screen Detail View (optional)
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
### Props (`BuiltinPortalProps<Args, State>`)
```ts
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
apiName?: string;
arguments: Arguments;
identifier: string;
messageId: string;
state: State;
}
```
### Canonical example — Web-Browsing Portal
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
```tsx
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '../../types';
import PageContent from './PageContent';
import PageContents from './PageContents';
import Search from './Search';
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
switch (apiName) {
case WebBrowsingApiName.search:
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
case WebBrowsingApiName.crawlSinglePage: {
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
return <PageContent messageId={messageId} result={result} />;
}
case WebBrowsingApiName.crawlMultiPages:
return (
<PageContents
messageId={messageId}
results={(state as CrawlPluginState).results}
urls={args.urls}
/>
);
}
return null;
});
export default Portal;
```
### Portal rules
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
### Portal registry — `packages/builtin-tools/src/portals.ts`
```ts
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
import { type BuiltinPortal } from '@lobechat/types';
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
};
```
---
## 7. `client/components/` — Shared Subcomponents
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
Examples from `web-browsing/src/client/components/`:
- `CategoryAvatar.tsx` — search category icon
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
Examples from `local-system/src/client/components/`:
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
### Rules
- Live under `client/components/`, exported via `client/components/index.ts`.
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
---
## 8. `client/index.ts` — Package Public API
Re-exports everything the registries need plus useful types/manifest:
```ts
// Inspector — required
export { TaskInspectors } from './Inspector';
// Render — only if any API has one
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
// Placeholder / Streaming / Intervention — only if used
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
export { LocalSystemStreamings } from './Streaming';
export { LocalSystemInterventions } from './Intervention';
// Portal — single export per tool
export { default as WebBrowsingPortal } from './Portal';
// Reusable components if other packages need them
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
// Re-export manifest, identifier, types for convenience
export { TaskManifest, TaskIdentifier } from '../manifest';
export * from '../types';
```
---
## 9. Diagnostic Quick-Lookup
| Symptom | Surface to check | | |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- |
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | |
| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback |
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | |
| Empty result card under header | Render returned `<div />` instead of `null` when no data | | |
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | |
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | |
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | |
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | |
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | |
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | |
+1
View File
@@ -8,6 +8,7 @@ description: >
(4) Send interactive cards or stream AI responses to chat platforms.
Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter",
building bots that work across multiple chat platforms.
user-invocable: false
---
# Chat SDK
+60 -35
View File
@@ -8,16 +8,20 @@ Generate text, images, videos, speech, and transcriptions.
```
lh generate (alias: gen)
├── text <prompt> # Text generation
├── image <prompt> # Image generation
├── video <prompt> # Video generation
├── tts <text> # Text-to-speech
├── asr <audioFile> # Audio-to-text (speech recognition)
├── download <genId> <taskId> # Wait & download generation result
├── status <genId> <taskId> # Check async task status
└── list # List generation topics
├── text <prompt> # Text generation
├── image <prompt> # Image generation
├── video <prompt> # Video generation
├── tts <text> # Text-to-speech
├── asr <audioFile> # Audio-to-text (speech recognition)
├── download <generationId> <asyncTaskId> # Wait & download generation result
├── status <generationId> <asyncTaskId> # Check async task status
└── list # List generation topics
```
> ⚠️ **Important**: `status` and `download` require an `asyncTaskId` (UUID format, e.g.
> `7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`), **not** the generation ID (`gen_xxx`).
> The asyncTaskId is printed after "→ Task" in the `video` / `image` command output.
---
## `lh generate text <prompt>` / `lh gen text <prompt>`
@@ -54,7 +58,7 @@ cat README.md | lh gen text "summarize this" --pipe
## `lh generate image <prompt>` / `lh gen image <prompt>`
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + task ID for tracking.
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + async task ID for tracking.
**Source**: `apps/cli/src/commands/generate/image.ts`
@@ -80,17 +84,22 @@ lh gen image "A cute cat" --model dall-e-3 --provider openai --json
✓ Image generation started
Batch ID: gb_xxx
1 image(s) queued
Generation gen_xxx → Task <taskId>
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the asyncTaskId — use this for status/download
Use "lh generate status <generationId> <taskId>" to check progress.
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
```
**Typical workflow**:
```bash
# Generate image, then wait & download
# 1. Submit generation — note down BOTH IDs from the output
lh gen image "A cute cat"
lh gen download <generationId> <taskId> -o cat.png
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
# 2. Wait & download using generationId + asyncTaskId (the UUID)
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o cat.png
```
---
@@ -102,7 +111,7 @@ Generate video from text prompt. This is an async operation.
**Source**: `apps/cli/src/commands/generate/video.ts`
```bash
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
lh gen video "A cat playing piano" -m <model> -p <provider> [options]
```
| Option | Description | Required |
@@ -122,9 +131,26 @@ lh gen video "A cat playing piano" -m < model > -p < provider > [options]
```
✓ Video generation started
Batch ID: gb_xxx
Generation gen_xxx → Task <taskId>
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the asyncTaskId — use this for status/download
Use "lh generate status <generationId> <taskId>" to check progress.
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
```
**Typical workflow**:
```bash
# 1. Find available video models for a provider
lh model list volcengine --json | grep -i seedance
# 2. Submit generation — note down BOTH IDs from the output
lh gen video "A cat on a runway" -m doubao-seedance-2-0-260128 -p volcengine \
--aspect-ratio 9:16 --duration 5 --resolution 1080p
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
# 3. Wait & download using generationId + asyncTaskId (the UUID)
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o result.mp4 --timeout 600
```
---
@@ -153,15 +179,18 @@ lh gen asr recording.wav [options]
---
## `lh generate download <generationId> <taskId>`
## `lh generate download <generationId> <asyncTaskId>`
Wait for an async generation task to complete and download the result file.
**Source**: `apps/cli/src/commands/generate/index.ts`
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
```bash
lh gen download <generationId> <taskId> [-o output.png]
lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
lh gen download <generationId> <asyncTaskId> [-o output.png]
lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result.mp4 --timeout 600
```
| Option | Description | Default |
@@ -175,30 +204,21 @@ lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
1. Polls `generation.getGenerationStatus` at the specified interval
2. Shows live progress: `⋯ Status: processing... (42s)`
3. On success: downloads asset URL to local file
4. On error: displays error message and exits
4. On error / wrong ID: displays a clear message pointing to the correct ID format
5. On timeout: suggests using `lh gen status` to check later
**Typical workflow**:
```bash
# One-shot: generate and download
lh gen image "A sunset"
# Copy the generation ID and task ID from output
lh gen download gen_xxx taskId_xxx -o sunset.png
# Video (longer timeout)
lh gen video "A cat running" -m model -p provider
lh gen download gen_xxx taskId_xxx -o cat.mp4 --timeout 600
```
---
## `lh generate status <generationId> <taskId>`
## `lh generate status <generationId> <asyncTaskId>`
Check the status of an async generation task.
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
```bash
lh gen status <generationId> <taskId> [--json]
lh gen status <generationId> <asyncTaskId> [--json]
lh gen status gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```
| Option | Description |
@@ -235,12 +255,17 @@ Image and video generation use an async task pattern:
- Triggers async background task (image via `createAsyncCaller`, video via `initModelRuntimeFromDB`)
- Returns `{ data: { batch, generations }, success }` with `asyncTaskId` in each generation
3. **Poll status**`generation.getGenerationStatus`
- Input: `{ generationId, asyncTaskId }` — both are required, and `asyncTaskId` must be the
UUID from the `async_tasks` table, not `gen_xxx`
- Returns `{ status, error, generation }` (generation includes asset URLs on success)
- Before querying, calls `checkTimeoutTasks` which marks tasks as `error` if they have been
`pending` or `processing` for more than ~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
**Server routes**:
- `src/server/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
- `src/server/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
- `src/server/routers/lambda/generation.ts` — status checking
- `packages/database/src/models/asyncTask.ts``AsyncTaskModel` including `checkTimeoutTasks`
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
# Walkthrough: Adding a New Feature End-to-End
This is a worked example of the canonical 6-step recipe applied to a new entity (`Dataset`), showing a variant of the main skill's pattern: **a list keyed by a parent id** (`datasetMap[benchmarkId]`), useful when the same shape appears under different parents.
If you only need the canonical (single-array) pattern, the main `SKILL.md` already shows it for `Benchmark`. Read this file when you need the parent-keyed Map variant, or when you want a checklist-style walkthrough.
## Step 1: Add Service methods
```typescript
class AgentEvalService {
async listDatasets(benchmarkId: string) {
return lambdaClient.agentEval.listDatasets.query({ benchmarkId });
}
async getDataset(id: string) {
return lambdaClient.agentEval.getDataset.query({ id });
}
async createDataset(params: CreateDatasetParams) {
return lambdaClient.agentEval.createDataset.mutate(params);
}
// updateDataset / deleteDataset follow the same shape
}
```
## Step 2: Reducer (optimistic updates)
```typescript
// src/store/eval/slices/dataset/reducer.ts
export type DatasetDispatch =
| { type: 'addDataset'; value: Dataset }
| { type: 'updateDataset'; id: string; value: Partial<Dataset> }
| { type: 'deleteDataset'; id: string };
export const datasetReducer = (state: Dataset[] = [], payload: DatasetDispatch): Dataset[] =>
produce(state, (draft) => {
switch (payload.type) {
case 'addDataset':
draft.unshift(payload.value);
break;
case 'updateDataset': {
const i = draft.findIndex((item) => item.id === payload.id);
if (i !== -1) draft[i] = { ...draft[i], ...payload.value };
break;
}
case 'deleteDataset': {
const i = draft.findIndex((item) => item.id === payload.id);
if (i !== -1) draft.splice(i, 1);
break;
}
}
});
```
## Step 3: Store slice
```typescript
// src/store/eval/slices/dataset/initialState.ts
export interface DatasetData {
currentPage: number;
hasMore: boolean;
isLoading: boolean;
items: Dataset[];
pageSize: number;
total: number;
}
export interface DatasetSliceState {
// Map keyed by benchmarkId — multiple parent contexts share the slice
datasetMap: Record<string, DatasetData>;
// Single item for modal display
datasetDetail: Dataset | null;
isLoadingDatasetDetail: boolean;
loadingDatasetIds: string[];
}
export const datasetInitialState: DatasetSliceState = {
datasetMap: {},
datasetDetail: null,
isLoadingDatasetDetail: false,
loadingDatasetIds: [],
};
```
```typescript
// src/store/eval/slices/dataset/action.ts
const FETCH_DATASETS_KEY = 'FETCH_DATASETS';
const FETCH_DATASET_DETAIL_KEY = 'FETCH_DATASET_DETAIL';
export const createDatasetSlice: StateCreator<EvalStore, any, [], DatasetAction> = (set, get) => ({
// Cache key includes benchmarkId so each parent has its own SWR entry
useFetchDatasets: (benchmarkId) =>
useClientDataSWR(
benchmarkId ? [FETCH_DATASETS_KEY, benchmarkId] : null,
() => agentEvalService.listDatasets(benchmarkId!),
{
onSuccess: (data) => {
set({
datasetMap: {
...get().datasetMap,
[benchmarkId!]: {
currentPage: 1,
hasMore: false,
isLoading: false,
items: data,
pageSize: data.length,
total: data.length,
},
},
});
},
},
),
useFetchDatasetDetail: (id) =>
useClientDataSWR(
id ? [FETCH_DATASET_DETAIL_KEY, id] : null,
() => agentEvalService.getDataset(id!),
{
onSuccess: (data) => set({ datasetDetail: data, isLoadingDatasetDetail: false }),
},
),
refreshDatasets: (benchmarkId) => mutate([FETCH_DATASETS_KEY, benchmarkId]),
refreshDatasetDetail: (id) => mutate([FETCH_DATASET_DETAIL_KEY, id]),
// CREATE with optimistic update — note the temp id pattern
createDataset: async (params) => {
const tmpId = Date.now().toString();
const { benchmarkId } = params;
get().internal_dispatchDataset(
{ type: 'addDataset', value: { ...params, id: tmpId, createdAt: Date.now() } as any },
benchmarkId,
);
get().internal_updateDatasetLoading(tmpId, true);
try {
const result = await agentEvalService.createDataset(params);
await get().refreshDatasets(benchmarkId);
return result;
} finally {
get().internal_updateDatasetLoading(tmpId, false);
}
},
// UPDATE / DELETE follow the same optimistic + refresh pattern as BenchmarkSlice
// (see the main SKILL.md)
// Internal — dispatch reducer scoped to a parent
internal_dispatchDataset: (payload, benchmarkId) => {
const currentData = get().datasetMap[benchmarkId];
const nextItems = datasetReducer(currentData?.items, payload);
// Skip set when nothing changed — avoids unnecessary re-renders
if (isEqual(nextItems, currentData?.items)) return;
set({
datasetMap: {
...get().datasetMap,
[benchmarkId]: {
...currentData,
currentPage: currentData?.currentPage ?? 1,
hasMore: currentData?.hasMore ?? false,
isLoading: false,
items: nextItems,
pageSize: currentData?.pageSize ?? nextItems.length,
total: currentData?.total ?? nextItems.length,
},
},
});
},
internal_updateDatasetLoading: (id, loading) => {
set((state) => ({
loadingDatasetIds: loading
? [...state.loadingDatasetIds, id]
: state.loadingDatasetIds.filter((i) => i !== id),
}));
},
});
```
## Step 4: Wire into the store
```typescript
// src/store/eval/store.ts
export type EvalStore = EvalStoreState & BenchmarkAction & DatasetAction & RunAction;
const createStore: StateCreator<EvalStore, [['zustand/devtools', never]]> = (set, get, store) => ({
...initialState,
...createBenchmarkSlice(set, get, store),
...createDatasetSlice(set, get, store),
...createRunSlice(set, get, store),
});
// src/store/eval/initialState.ts
export const initialState: EvalStoreState = {
...benchmarkInitialState,
...datasetInitialState,
...runInitialState,
};
```
## Step 5: Selectors (optional but recommended)
```typescript
export const datasetSelectors = {
getDatasetData: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId],
getDatasets: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId]?.items ?? [],
isLoadingDataset: (id: string) => (s: EvalStore) => s.loadingDatasetIds.includes(id),
};
```
## Step 6: Use in component
```tsx
// List scoped to a parent
const DatasetList = ({ benchmarkId }: { benchmarkId: string }) => {
const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);
const datasets = useEvalStore(datasetSelectors.getDatasets(benchmarkId));
const datasetData = useEvalStore(datasetSelectors.getDatasetData(benchmarkId));
useFetchDatasets(benchmarkId);
if (datasetData?.isLoading) return <Loading />;
return (
<div>
<h2>Total: {datasetData?.total ?? 0}</h2>
<List data={datasets} />
</div>
);
};
// Single item for modal — conditional fetching pattern
const DatasetImportModal = ({ open, datasetId }: Props) => {
const useFetchDatasetDetail = useEvalStore((s) => s.useFetchDatasetDetail);
const dataset = useEvalStore((s) => s.datasetDetail);
const isLoading = useEvalStore((s) => s.isLoadingDatasetDetail);
// Only fetch when modal is open AND id present
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
return <Modal open={open}>{isLoading ? <Loading /> : <div>{dataset?.name}</div>}</Modal>;
};
```
+1
View File
@@ -1,6 +1,7 @@
---
name: db-migrations
description: 'Use when generating or regenerating Drizzle migration files, changing database schema tables or columns, resolving migration sequence conflicts after rebase, reviewing migration SQL for idempotent patterns, or renaming migration files.'
user-invocable: false
---
# Database Migrations Guide
@@ -1,6 +1,6 @@
---
name: debug
description: Debug package usage guide. Use when adding debug logging, understanding log namespaces, or implementing debugging features. Triggers on debug logging requests or logging implementation.
name: debug-package
description: "Guide for the `debug` npm package and LobeHub log namespaces (lobe-server:*, lobe-desktop:*, lobe-client:*, lobe-*-router:*). Use whenever adding a `debug(...)` logger, picking a namespace for new server/desktop/client/router code, troubleshooting why DEBUG=lobe-* logs don't show up, or when the user asks to 'add logging', 'add a logger', 'instrument this', 'trace this call', 'why isn't my log printing', or mentions `debug(`, `DEBUG=`, `localStorage.debug`, or log format specifiers like %O / %o / %s / %d in a LobeHub codebase."
user-invocable: false
---
+3 -6
View File
@@ -1,6 +1,7 @@
---
name: drizzle
description: Drizzle ORM schema and database guide. Use when working with database schemas (src/database/schemas/*), defining tables, creating migrations, or database model code. Triggers on Drizzle schema definition, database migrations, or ORM usage questions.
description: "Drizzle ORM schema authoring and query style for LobeHub (postgres, strict mode). Use when editing anything under `src/database/schemas/`, defining `pgTable` columns/indexes/junction tables, spreading `...timestamps`, generating `createInsertSchema`/`$inferSelect`/`$inferInsert` types, writing `db.select().from(...).leftJoin(...)` queries, or deciding when to split a relational `with:` into two queries. Triggers on `pgTable`, `db.select`, `db.query`, `eq()`/`and()`/`inArray()`, `uniqueIndex`, `primaryKey`, `references({ onDelete })`, 'add a column', 'new table', 'foreign key', 'junction table', 'schema field'. For migration files specifically, see the `db-migrations` skill."
user-invocable: false
---
# Drizzle ORM Schema Style Guide
@@ -125,11 +126,7 @@ The relational API generates complex lateral joins with `json_build_array` that
```typescript
// ✅ Good
const [result] = await this.db
.select()
.from(agents)
.where(eq(agents.id, id))
.limit(1);
const [result] = await this.db.select().from(agents).where(eq(agents.id, id)).limit(1);
return result;
// ❌ Bad: relational API
+2 -1
View File
@@ -1,6 +1,7 @@
---
name: hotkey
description: Guide for adding keyboard shortcuts. Use when implementing new hotkeys, registering shortcuts, or working with keyboard interactions. Triggers on hotkey implementation or keyboard shortcut tasks.
description: "Adding or editing keyboard shortcuts in LobeHub. Use when registering a new hotkey, changing a key combo, scoping a shortcut to chat vs global, or wiring a hotkey hook + tooltip. Covers the 5-step flow: add to `HotkeyEnum` in `src/types/hotkey.ts`, register in `HOTKEYS_REGISTRATION` (`src/const/hotkeys.ts`) with `combineKeys([Key.Mod, …])`, add i18n in `src/locales/default/hotkey.ts`, expose via `useHotkeyById` in `src/hooks/useHotkeys/`, and render `<Tooltip hotkey={…}>`. Triggers on `HotkeyEnum`, `HOTKEYS_REGISTRATION`, `useHotkeyById`, `combineKeys`, `Key.Mod`/`Key.Shift`, 'add a hotkey', 'add a shortcut', '加快捷键', '快捷键', 'Cmd+K', 'keyboard shortcut', 'hotkey scope', 'hotkey conflict'."
user-invocable: false
---
# Adding Keyboard Shortcuts Guide
+2 -1
View File
@@ -1,6 +1,7 @@
---
name: i18n
description: Internationalization guide using react-i18next. Use when adding translations, creating i18n keys, or working with localized text in React components (.tsx files). Triggers on translation tasks, locale management, or i18n implementation.
description: "LobeHub internationalization with react-i18next. Use when adding any user-facing string in `.tsx`/`.ts` files, creating or renaming a key under `src/locales/default/{namespace}.ts`, deciding the `{feature}.{context}.{action}` flat-key pattern, wiring a new namespace into `src/locales/default/index.ts`, or translating zh-CN/en-US JSON for dev preview. Triggers on `useTranslation`, `t('foo.bar')`, `i18next.t`, `{{variable}}` interpolation, hardcoded UI strings (zh or en) that should be extracted, 'add i18n', '加 i18n key', '翻译', 'locale key', 'namespace', 'pnpm i18n'."
user-invocable: false
---
# LobeHub Internationalization Guide
+38 -33
View File
@@ -1,44 +1,55 @@
---
name: linear
description: "Linear issue management. MUST USE when: (1) user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), (2) user says 'linear', 'linear issue', 'link linear', (3) creating PRs that reference Linear issues. Provides workflows for retrieving issues, updating status, and adding comments."
description: "Linear issue management. Use when the user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), says 'linear' / 'linear issue' / 'link linear', or when creating PRs that reference Linear issues. Covers retrieving issues, updating status, adding completion comments, and creating sub-issue trees."
user-invocable: false
---
# Linear Issue Management
Before using Linear workflows, search for `linear` MCP tools. If not found, treat as not installed.
## ⚠️ CRITICAL: PR Creation with Linear Issues
## PR Creation with Linear Issues
**When creating a PR that references Linear issues (LOBE-xxx), you MUST:**
A PR that fixes a Linear issue has **two separate jobs to do**, and both matter:
1. Create the PR with magic keywords (`Fixes LOBE-xxx`)
2. **IMMEDIATELY after PR creation**, add completion comments to ALL referenced Linear issues
3. Do NOT consider the task complete until Linear comments are added
1. **`Fixes LOBE-xxx` in the PR body** — Linear watches GitHub for these magic keywords and auto-links the PR and auto-closes the issue on merge. This is the machine-readable side.
2. **A completion comment on the Linear issue** — gives the reviewer/PM/teammate landing in Linear a human-readable summary of what changed and why, without forcing them to click through to GitHub and read a diff.
This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
If you only do step 1, Linear watchers (often non-engineers) hit the issue and see no context. So pair PR creation with the Linear comment as part of the same task — finish both before considering the work done.
## Workflow
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
2. **Read images**: If the issue description contains images, MUST use `mcp__linear-server__extract_images` to read image content for full context
3. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
4. **Mark as In Progress**: When starting to plan or implement an issue, immediately update status to **"In Progress"** via `mcp__linear-server__update_issue`
2. **Read images** issue descriptions often contain screenshots with critical context (mockups, error states, before/after). Use `mcp__linear-server__extract_images` so you actually see them; reading raw markdown alone misses what the reporter was looking at.
3. **Check for sub-issues**: `mcp__linear-server__list_issues` with `parentId` filter
4. **Mark as In Progress** at the moment you start planning or implementing — this signals to teammates the issue is owned, so they don't double-pick it up.
5. **Update issue status** when completing: `mcp__linear-server__update_issue`
6. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
6. **Add completion comment** (see [format below](#completion-comment-format))
## Creating Issues
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
When creating issues with `mcp__linear-server__create_issue`, add the `claude code` label. Reason: the label is how the team filters/audits AI-generated issues; without it those issues vanish into the general backlog and the team loses visibility into AI contribution patterns.
## Language
Match the issue language to the conversation that produced it — if you're discussing in 中文,write the issue in 中文;if discussing in English, write it in English. Reason: the issue is a continuation of the conversation, and forcing a language switch creates translation friction for the collaborator who started the thread.
Specifics:
- 中文 conversation → 中文 body; technical terms (file paths, identifiers, library names, commands, error messages) stay in English.
- English conversation → English body.
- Code blocks, file paths, and quoted strings always stay in their original form regardless of surrounding language.
- This applies equally to **updates** — when editing an existing issue (description **and titles**), preserve the language of the conversation that triggered the edit; don't switch the issue language mid-refactor.
## Creating Sub-issue Trees
When breaking a parent issue into a tree of sub-issues (e.g., task decomposition for LOBE-xxx), follow these rules — they work around real limitations of the Linear MCP tools.
### 1. ALWAYS prefix titles with an ordering index
### 1. Prefix titles with an ordering index
The Linear Sub-issues panel displays children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation will produce the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you cannot set order at create time.
The Linear Sub-issues panel orders children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation produces the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you can't set order at create time.
**Workaround**: encode execution order in the title itself:
Workaround: encode execution order in the title itself:
```plaintext
[1] [db] add schema fields
@@ -89,7 +100,7 @@ The implementer may open only the sub-issue, not the parent — don't rely on co
## Completion Comment Format
Every completed issue MUST have a comment summarizing work done:
Each completed issue gets a comment summarizing the work, so reviewers and future readers don't have to reconstruct it from the PR diff:
```markdown
## Changes Summary
@@ -105,34 +116,28 @@ Every completed issue MUST have a comment summarizing work done:
- ...
```
This is critical for:
This gives team visibility, code-review context, and a paper trail for future reference.
- Team visibility
- Code review context
- Future reference
## PR Association
## PR Association (REQUIRED)
When creating PRs for Linear issues, include magic keywords in PR body:
When creating PRs for Linear issues, include magic keywords in the PR body:
- `Fixes LOBE-123`
- `Closes LOBE-123`
- `Resolves LOBE-123`
These trigger Linear's auto-link + auto-close on merge.
## Per-Issue Completion Rule
When working on multiple issues, update EACH issue IMMEDIATELY after completing it:
When working on multiple issues, close out **each one before starting the next** — don't batch all the Linear updates to the end. Batching is where comments get forgotten and issues stay stuck in "In Progress" days after the PR shipped.
For each issue:
1. Complete implementation
2. Run `bun run type-check`
3. Run related tests
4. Create PR if needed
5. Update status to **"In Review"** (NOT "Done")
6. **Add completion comment immediately**
7. Move to next issue
**Note:** Status → "In Review" when PR created. "Done" only after PR merged.
**❌ Wrong:** Complete all → Create PR → Forget Linear comments
**✅ Correct:** Complete → Create PR → Add Linear comments → Task done
5. Update status to **"In Review"** (not "Done" — "Done" is for after the PR merges)
6. Add the completion comment
7. Move to the next issue
@@ -11,86 +11,169 @@
# Environment variables:
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
# ELECTRON_WAIT_S — Max seconds to wait for CDP to become reachable (default: 90)
# RENDERER_WAIT_S — Max seconds to wait for SPA after CDP is up (default: 60)
# FORCE_KILL_USER — When set to 1, silently kill the user's `bun run dev`
# Electron without confirmation (default: always confirm-by-action)
#
set -euo pipefail
CDP_PORT="${CDP_PORT:-9222}"
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-90}"
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
# Project-scoped electron path prefix used for pgrep matching. Any Electron
# binary from this project (main + helpers, with or without --remote-debugging-port)
# starts with this string in its argv[0], so a single substring match catches all.
PROJECT_ELECTRON_PATH="${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@"
# ── Helpers ──────────────────────────────────────────────────────────
# Get the Electron binary path used by this project
electron_bin_pattern() {
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
# Print pid + every descendant pid (DFS via pgrep -P).
expand_descendants() {
local pid="$1"
echo "$pid"
local children
children=$(pgrep -P "$pid" 2>/dev/null || true)
for c in $children; do
expand_descendants "$c"
done
}
# Find all PIDs related to the project's Electron dev session
find_electron_pids() {
# Find seed PIDs related to this project's Electron dev session.
# Matches REGARDLESS of whether --remote-debugging-port was passed, so it also
# catches a plain `bun run dev` session the user started outside this script.
find_project_pids() {
local pids=""
# 1. Main Electron process (launched with --remote-debugging-port)
local main_pids
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
[ -n "$main_pids" ] && pids="$pids $main_pids"
# 1. Any process whose command line mentions this project's electron path
# (covers the main Electron binary AND every Helper subprocess)
local electron_pids
electron_pids=$(pgrep -f "$PROJECT_ELECTRON_PATH" 2>/dev/null || true)
pids="$pids $electron_pids"
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
local helper_pids
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
# 3. electron-vite dev server
# 2. electron-vite dev server (narrow match to avoid catching unrelated Vite invocations)
local vite_pids
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
vite_pids=$(pgrep -f "electron-vite[/.].*\\bdev\\b" 2>/dev/null || true)
pids="$pids $vite_pids"
# 4. PID from pidfile (fallback)
# 3. The launcher subshell from a previous `start` (saved to pidfile)
if [ -f "$PIDFILE" ]; then
local saved_pid
saved_pid=$(cat "$PIDFILE")
if kill -0 "$saved_pid" 2>/dev/null; then
saved_pid=$(cat "$PIDFILE" 2>/dev/null || true)
if [ -n "$saved_pid" ] && kill -0 "$saved_pid" 2>/dev/null; then
pids="$pids $saved_pid"
fi
fi
# Deduplicate
# 4. Whatever is currently bound to the CDP port — catches strays whose
# binary path doesn't match (e.g. orphaned from a crashed restart)
local port_pid
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
pids="$pids $port_pid"
# `|| true` because `grep -v '^$'` exits 1 when input has no non-empty
# lines, which (with pipefail + set -e) silently kills the caller.
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
}
# Wait for the CDP HTTP endpoint to respond, with a deadline + early bail-out
# if the launcher process died (no point waiting if Electron crashed).
wait_for_cdp() {
local deadline=$(( $(date +%s) + ELECTRON_WAIT_S ))
echo "[electron-dev] Waiting for CDP on port ${CDP_PORT} (up to ${ELECTRON_WAIT_S}s)..."
while [ "$(date +%s)" -lt "$deadline" ]; do
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
echo "[electron-dev] CDP is reachable."
return 0
fi
# If our launcher subshell died, abort early so we don't hang the full timeout
if [ -f "$PIDFILE" ]; then
local saved_pid
saved_pid=$(cat "$PIDFILE" 2>/dev/null || true)
if [ -n "$saved_pid" ] && ! kill -0 "$saved_pid" 2>/dev/null; then
echo "[electron-dev] Launcher PID $saved_pid is gone before CDP came up."
echo "[electron-dev] Last 30 lines of $ELECTRON_LOG:"
tail -30 "$ELECTRON_LOG" 2>/dev/null || true
return 1
fi
fi
sleep 2
done
echo "[electron-dev] ERROR: CDP did not respond within ${ELECTRON_WAIT_S}s"
echo "[electron-dev] Last 30 lines of $ELECTRON_LOG:"
tail -30 "$ELECTRON_LOG" 2>/dev/null || true
return 1
}
# After CDP is up, wait until the SPA renders interactive elements.
wait_for_renderer() {
local deadline=$(( $(date +%s) + RENDERER_WAIT_S ))
echo "[electron-dev] Waiting for SPA to load (up to ${RENDERER_WAIT_S}s)..."
while [ "$(date +%s)" -lt "$deadline" ]; do
local snap
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
if echo "$snap" | grep -qE '\b(link|button)\b'; then
echo "[electron-dev] Renderer ready."
return 0
fi
sleep 2
done
echo "[electron-dev] WARNING: Renderer not interactive within ${RENDERER_WAIT_S}s — proceeding anyway."
return 0
}
# ── Commands ─────────────────────────────────────────────────────────
do_stop() {
echo "[electron-dev] Stopping Electron dev environment..."
local pids
pids=$(find_electron_pids)
local seed_pids
seed_pids=$(find_project_pids)
if [ -z "$pids" ]; then
echo "[electron-dev] No Electron processes found."
# Expand to include all descendants — catches helpers spawned by the main
# process AFTER our pgrep snapshot, and the launcher's child node/electron-vite
# process tree.
local all_pids=""
for pid in $seed_pids; do
all_pids="$all_pids $(expand_descendants "$pid")"
done
all_pids=$(echo "$all_pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true)
if [ -z "$all_pids" ]; then
echo "[electron-dev] No project Electron/vite processes found."
else
echo "[electron-dev] Killing PIDs: $pids"
for pid in $pids; do
local count
count=$(echo "$all_pids" | tr ' ' '\n' | grep -c .)
echo "[electron-dev] Sending SIGTERM to $count process(es): $all_pids"
for pid in $all_pids; do
kill "$pid" 2>/dev/null || true
done
# Wait up to 5s for graceful exit, then force-kill survivors
# Wait up to 5s for graceful exit
local waited=0
while [ $waited -lt 5 ]; do
local alive=""
for pid in $pids; do
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
local any_alive=0
for pid in $all_pids; do
if kill -0 "$pid" 2>/dev/null; then any_alive=1; break; fi
done
[ -z "$alive" ] && break
[ "$any_alive" = "0" ] && break
sleep 1
waited=$((waited + 1))
done
# Force-kill any remaining
for pid in $pids; do
# SIGKILL anyone still alive
for pid in $all_pids; do
if kill -0 "$pid" 2>/dev/null; then
echo "[electron-dev] Force-killing PID $pid"
kill -9 "$pid" 2>/dev/null || true
@@ -98,7 +181,27 @@ do_stop() {
done
fi
# Also close any agent-browser sessions connected to this port
# Belt-and-suspenders: anything still bound to the CDP port goes away
local port_pid
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "$port_pid" ]; then
echo "[electron-dev] Port $CDP_PORT still bound by PID $port_pid; force-killing"
# shellcheck disable=SC2086
kill -9 $port_pid 2>/dev/null || true
fi
# Also re-sweep the project's electron processes — sometimes the OS spawns
# new helpers during shutdown that didn't exist when we first enumerated.
local stragglers
stragglers=$(pgrep -f "$PROJECT_ELECTRON_PATH" 2>/dev/null || true)
if [ -n "$stragglers" ]; then
echo "[electron-dev] Cleaning up stragglers: $stragglers"
for pid in $stragglers; do
kill -9 "$pid" 2>/dev/null || true
done
fi
# Close any agent-browser sessions connected to this port
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
rm -f "$PIDFILE"
@@ -107,113 +210,91 @@ do_stop() {
do_status() {
local pids
pids=$(find_electron_pids)
pids=$(find_project_pids)
if [ -z "$pids" ]; then
echo "[electron-dev] Electron is NOT running."
echo "[electron-dev] No project Electron processes found."
return 1
fi
echo "[electron-dev] Electron is running (PIDs: $pids)"
echo "[electron-dev] Project processes: $pids"
# Check CDP connectivity
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
local url
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1 || echo "?")
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
return 0
else
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (no --remote-debugging-port, or still loading)."
return 2
fi
}
wait_for_electron() {
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
local elapsed=0
local interval=3
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
echo "[electron-dev] Electron process started."
return 0
fi
sleep "$interval"
elapsed=$((elapsed + interval))
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
done
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
echo "[electron-dev] Last 20 lines of log:"
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
return 1
}
wait_for_renderer() {
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
# Initial delay — renderer needs time to bootstrap
sleep 10
local elapsed=10
local interval=5
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
# Check if interactive elements are present (SPA loaded)
local snap
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
if echo "$snap" | grep -qE 'link |button '; then
echo "[electron-dev] Renderer ready (interactive elements found)."
return 0
fi
fi
sleep "$interval"
elapsed=$((elapsed + interval))
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
done
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
return 0
}
do_start() {
# If already running and healthy, skip
local status_ok=0
do_status >/dev/null 2>&1 || status_ok=$?
if [ "$status_ok" -eq 0 ]; then
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
# Already up and CDP is reachable → nothing to do
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
echo "[electron-dev] CDP already reachable on port $CDP_PORT. Skipping start."
echo "[electron-dev] Use 'restart' to force a fresh session."
return 0
fi
# Clean up any stale processes
# Detect the user's existing dev session (or stale processes) BEFORE killing
local existing
existing=$(find_project_pids)
if [ -n "$existing" ]; then
echo "[electron-dev] Existing project Electron/vite processes detected:"
echo "$existing" | tr ' ' '\n' | sed 's/^/[electron-dev] PID /'
echo "[electron-dev] Tearing them down so we can start a CDP-enabled session..."
fi
do_stop
# Start fresh
# Wait for port + user-data-dir locks to release. Without this, the new
# Electron may fail with "user data directory in use" or fail to bind CDP.
local waited=0
while [ $waited -lt 10 ]; do
if ! lsof -i tcp:"$CDP_PORT" >/dev/null 2>&1 \
&& ! pgrep -f "$PROJECT_ELECTRON_PATH" >/dev/null 2>&1; then
break
fi
[ $waited -eq 0 ] && echo "[electron-dev] Waiting for port + Electron locks to release..."
sleep 1
waited=$((waited + 1))
done
echo "[electron-dev] Starting Electron dev server..."
echo "[electron-dev] Project: $PROJECT_ROOT"
echo "[electron-dev] Project: $PROJECT_ROOT"
echo "[electron-dev] CDP port: $CDP_PORT"
echo "[electron-dev] Log: $ELECTRON_LOG"
echo "[electron-dev] Log: $ELECTRON_LOG"
: > "$ELECTRON_LOG" # Truncate log
(
cd "$PROJECT_ROOT/apps/desktop" && \
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
>> "$ELECTRON_LOG" 2>&1
) &
local bg_pid=$!
echo "$bg_pid" > "$PIDFILE"
echo "[electron-dev] Background PID: $bg_pid"
# Launch in a new session (setsid) so the whole process tree shares a PGID
# we can later signal in one shot. `setsid bash -c '... exec ...' &` keeps
# the bash shell as the session leader; its PID is what we save.
# macOS doesn't ship setsid by default — fall back to plain bash; cleanup
# still works via `expand_descendants` walking the process tree.
local launch_cmd="
cd '$PROJECT_ROOT/apps/desktop'
exec npx electron-vite dev -- --remote-debugging-port=$CDP_PORT
"
if command -v setsid >/dev/null 2>&1; then
setsid bash -c "$launch_cmd" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
else
bash -c "$launch_cmd" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
fi
local launcher_pid=$!
echo "$launcher_pid" > "$PIDFILE"
echo "[electron-dev] Launcher PID (session leader): $launcher_pid"
# Wait for Electron process to start
if ! wait_for_electron; then
echo "[electron-dev] Failed to start. Cleaning up..."
if ! wait_for_cdp; then
echo "[electron-dev] Failed to bring up CDP. Cleaning up..."
do_stop
return 1
fi
# Wait for renderer to be interactive
if ! wait_for_renderer; then
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
echo "[electron-dev] Renderer not interactive — you may need to wait more."
fi
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
@@ -221,7 +302,7 @@ do_start() {
do_restart() {
do_stop
sleep 2
sleep 1
do_start
}
@@ -235,10 +316,12 @@ case "${1:-help}" in
*)
echo "Usage: $0 {start|stop|status|restart}"
echo ""
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
echo " status — Check if Electron is running and CDP is reachable"
echo " restart — Stop then start"
echo " start — Start Electron dev with CDP. Detects + tears down any"
echo " existing project Electron (e.g. \`bun run dev\`) first."
echo " stop — Kill all project Electron/vite processes (main + helpers"
echo " + descendants), with SIGTERM → 5s wait → SIGKILL fallback."
echo " status — Check if Electron is running and CDP is reachable."
echo " restart — Stop then start."
exit 1
;;
esac
+6
View File
@@ -1,10 +1,16 @@
---
name: microcopy
description: UI copy and microcopy guidelines. Use when writing UI text, buttons, error messages, empty states, onboarding, or any user-facing copy. Triggers on i18n translation, UI text writing, or copy improvement tasks. Supports both Chinese and English.
user-invocable: false
---
# LobeHub UI Microcopy Guidelines
This file is the quick-reference summary. For full prompt-style guidelines with extensive examples (anti-patterns, tone matrices, scenario walk-throughs), load the language-specific reference:
- **中文文案** — [`references/zh.md`](./references/zh.md)
- **English copy** — [`references/en.md`](./references/en.md)
Brand: **Where Agents Collaborate** - Focus on collaborative agent system, not just "generation".
## Fixed Terminology
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: modal
description: MUST use when creating, editing, or writing modal dialogs or imperative modals. Prefer createModal / useModalContext / confirmModal from @lobehub/ui/base-ui; root @lobehub/ui is legacy (antd Modal). Covers patterns, ModalHost, and migration notes.
description: "LobeHub imperative-modal conventions. Use whenever creating, editing, opening, or migrating a modal/dialog/popup — prefer `createModal` / `confirmModal` / `useModalContext` from `@lobehub/ui/base-ui` (headless) over the legacy root `@lobehub/ui` `createModal` (antd Modal props) and over any declarative `open` state + `<Modal />` pattern. Covers required `ModalHost` mounting, the `Content` + `index.tsx` file layout, `content` vs `children` slot, i18n inside `createModal()` (`import { t } from 'i18next'`), and migration notes. Triggers on `createModal`, `confirmModal`, `useModalContext`, `ModalHost`, `antd Modal`, `<Modal open>`, 'open a modal', 'popup', 'dialog', 'confirm dialog', '弹框', '弹窗', '确认框', 'migrate to base-ui'."
user-invocable: false
---
+1
View File
@@ -1,6 +1,7 @@
---
name: project-overview
description: Complete project architecture and structure guide. Use when exploring the codebase, understanding project organization, finding files, or needing comprehensive architectural context. Triggers on architecture questions, directory navigation, or project overview needs.
user-invocable: false
---
# LobeHub Project Overview
+2 -1
View File
@@ -1,6 +1,7 @@
---
name: react
description: React component development guide. Use when working with React components (.tsx files), creating UI, using @lobehub/ui components, implementing routing, or building frontend features. Triggers on React component creation, modification, layout implementation, or navigation tasks.
description: "LobeHub React/SPA component conventions: antd-style with `createStaticStyles` + `cssVar.*` (prefer zero-runtime over `createStyles` + `token`), `@lobehub/ui/base-ui` primitives before `@lobehub/ui` before antd, `Flexbox`/`Center` for layouts, react-router-dom navigation, and the `.desktop.tsx` sync rule. Use when writing or editing any `.tsx` under `src/**`, picking a styling helper, choosing a component (Select/Modal/Drawer/Button/Tooltip), wiring routes in `desktopRouter.config.tsx`/`.desktop.tsx`, or adding a `Link`/`useNavigate` call in the SPA. Triggers on `createStyles`/`createStaticStyles`, `cssVar`, `@lobehub/ui`, `antd-style`, `Flexbox`, `useNavigate`, `react-router-dom`, `Link`, 'new component', 'add a page', 'edit a layout', 'desktopRouter', 'componentMap.desktop'."
user-invocable: false
---
# React Component Writing Guide
@@ -1,58 +1,52 @@
---
name: code-review
description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs, or code changes. Covers correctness, security, quality, and project-specific patterns.'
name: review-checklist
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
user-invocable: false
---
# Code Review Guide
# Review Checklist
## Before You Start
1. Read `/typescript` and `/testing` skills for code style and test conventions
2. Get the diff (skip if already in context, e.g., injected by GitHub review app): `git diff` or `git diff origin/canary..HEAD`
## Checklist
### Correctness
## Correctness
- Leftover `console.log` / `console.debug` — should use `debug` package or remove
- Missing `return await` in try/catch — see <https://typescript-eslint.io/rules/return-await/> (not in our ESLint config yet, requires type info)
- Can the fix/implementation be more concise, efficient, or have better compatibility?
### Security
## Security
- No sensitive data (API keys, tokens, credentials) in `console.*` or `debug()` output
- No base64 output to terminal — extremely long, freezes output
- No hardcoded secrets — use environment variables
### Testing
## Testing
- Bug fixes must include tests covering the fixed scenario
- New logic (services, store actions, utilities) should have test coverage
- Existing tests still cover the changed behavior?
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
### i18n
## i18n
- New user-facing strings use i18n keys, not hardcoded text
- Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming
- For PRs: `locales/` translations for all languages updated (`pnpm i18n`)
### SPA / routing
## SPA / routing
- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens.
### Reuse
## Reuse
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
- Copy-pasted blocks with slight variation — extract into shared function
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
- Use `antd-style` token system, not hardcoded colors; prefer `createStaticStyles` + `cssVar.*` over `createStyles` + `token` unless runtime computation is required
### Database
## Database
- Migration scripts must be idempotent (`IF NOT EXISTS`, `IF EXISTS` guards)
### Cloud Impact
## Cloud Impact
A downstream cloud deployment depends on this repo. Flag changes that may require cloud-side updates:
@@ -61,13 +55,3 @@ A downstream cloud deployment depends on this repo. Flag changes that may requir
- **Dependency versions bumped** — e.g., upgrading `next` or `drizzle-orm` in `package.json`
- **`@lobechat/business-*` exports changed** — e.g., renaming a function in `src/business/` or changing type signatures in `packages/business/`
- `src/business/` and `packages/business/` must not expose cloud commercial logic in comments or code
## Output Format
For local CLI review only (GitHub review app posts inline PR comments instead):
- Number all findings sequentially
- Indicate priority: `[high]` / `[medium]` / `[low]`
- Include file path and line number for each finding
- Only list problems — no summary, no praise
- Re-read full source for each finding to verify it's real, then output "All findings verified."
@@ -0,0 +1,44 @@
---
name: 'source-command-dedupe'
description: 'Find duplicate GitHub issues'
---
# source-command-dedupe
Use this skill when the user asks to run the migrated source command `dedupe`.
## Command Template
Find up to 3 likely duplicate issues for a given GitHub issue.
To do this, follow these steps precisely:
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
Notes (be sure to tell this to your agents, too):
- Use `gh` to interact with Github, rather than web fetch
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
- Make a todo list first
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
---
Found 3 possible duplicate issues:
1. <link to issue>
2. <link to issue>
3. <link to issue>
This issue will be automatically closed as a duplicate in 3 days.
- If your issue is a duplicate, please close it and 👍 the existing issue instead
- To prevent auto-closure, add a comment or 👎 this comment
> 🤖 Generated with Codex
---
+5 -4
View File
@@ -1,6 +1,7 @@
---
name: spa-routes
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
user-invocable: false
---
# SPA Routes and Features Guide
@@ -84,10 +85,10 @@ Each feature should:
## 3a. Desktop router pair (`desktopRouter.config` × 2)
| File | Role |
|------|------|
| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. |
| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. |
| File | Role |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. |
| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. |
Anything that changes the tree (new segment, renamed `path`, moved layout, new child route) must be reflected in **both** files in one PR or commit. Remove routes from both when deleting.
+70 -380
View File
@@ -1,257 +1,91 @@
---
name: store-data-structures
description: Zustand store data structure patterns for LobeHub. Covers List vs Detail data structures, Map + Reducer patterns, type definitions, and when to use each pattern. Use when designing store state, choosing data structures, or implementing list/detail pages.
user-invocable: false
---
# LobeHub Store Data Structures
This guide covers how to structure data in Zustand stores for optimal performance and user experience.
How to structure data in Zustand stores for fast list rendering, multi-detail caching, and ergonomic optimistic updates.
## Core Principles
### ✅ DO
1. **Separate List and Detail** - Use different structures for list pages and detail pages
2. **Use Map for Details** - Cache multiple detail pages with `Record<string, Detail>`
3. **Use Array for Lists** - Simple arrays for list display
4. **Types from @lobechat/types** - Never use `@lobechat/database` types in stores
5. **Distinguish List and Detail types** - List types may have computed UI fields
1. **Separate List and Detail** different structures for list pages and detail pages
2. **Use Map for Details** — cache multiple detail pages with `Record<string, Detail>`
3. **Use Array for Lists** — simple arrays for list display
4. **Types from `@lobechat/types`** — never use `@lobechat/database` types in stores
5. **Distinguish List and Detail types** List types may have computed UI fields
### ❌ DON'T
1. **Don't use single detail object** - Can't cache multiple pages
2. **Don't mix List and Detail types** - They have different purposes
3. **Don't use database types** - Use types from `@lobechat/types`
4. **Don't use Map for lists** - Simple arrays are sufficient
1. **Don't use a single detail object** — can't cache multiple pages
2. **Don't mix List and Detail types** — they have different purposes
3. **Don't use database types** — use types from `@lobechat/types`
4. **Don't use Map for lists** — simple arrays are sufficient
---
## Type Definitions
Types should be organized by entity in separate files:
Each entity gets its own file under `@lobechat/types/`. Each file exports two types:
```
@lobechat/types/src/eval/
├── benchmark.ts # Benchmark types
├── agentEvalDataset.ts # Dataset types
├── agentEvalRun.ts # Run types
└── index.ts # Re-exports
```
- **Detail type** — full entity, including heavy fields (rubrics, content, editor state, …)
- **List item type** — a **subset** that excludes heavy fields, may add computed UI fields (counts, timestamps formatted for display)
### Example: Benchmark Types
**Important:** the List type is a **subset**, not an `extends` of Detail. Extending pulls the heavy fields right back in.
```typescript
// packages/types/src/eval/benchmark.ts
import type { EvalBenchmarkRubric } from './rubric';
// ============================================
// Detail Type - Full entity (for detail pages)
// ============================================
/**
* Full benchmark entity with all fields including heavy data
*/
export interface AgentEvalBenchmark {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
metadata?: Record<string, unknown> | null;
name: string;
referenceUrl?: string | null;
rubrics: EvalBenchmarkRubric[]; // Heavy field
updatedAt: Date;
}
// ============================================
// List Type - Lightweight (for list display)
// ============================================
/**
* Lightweight benchmark item - excludes heavy fields
* May include computed statistics for UI
*/
export interface AgentEvalBenchmarkListItem {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
name: string;
// Note: rubrics NOT included (heavy field)
// Computed statistics for UI display
datasetCount?: number;
runCount?: number;
testCaseCount?: number;
}
```
### Example: Document Types (with heavy content)
```typescript
// packages/types/src/document.ts
/**
* Full document entity - includes heavy content fields
*/
export interface Document {
id: string;
title: string;
description?: string;
content: string; // Heavy field - full markdown content
editorData: any; // Heavy field - editor state
metadata?: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight document item - excludes heavy content
*/
export interface DocumentListItem {
id: string;
title: string;
description?: string;
// Note: content and editorData NOT included
createdAt: Date;
updatedAt: Date;
// Computed statistics
wordCount?: number;
lastEditedBy?: string;
}
```
**Key Points:**
- **Detail types** include ALL fields from database (full entity)
- **List types** are **subsets** that exclude heavy/large fields
- List types may add computed statistics for UI (e.g., `testCaseCount`)
- **Each entity gets its own file** (not mixed together)
- **All types** exported from `@lobechat/types`, NOT `@lobechat/database`
**Heavy fields to exclude from List:**
- Large text content (`content`, `editorData`, `fullDescription`)
- Complex objects (`rubrics`, `config`, `metrics`)
- Binary data (`image`, `file`)
- Large arrays (`messages`, `items`)
> See [`references/types.md`](./references/types.md) for full worked examples (Benchmark, Document) and the heavy-field exclusion checklist.
---
## When to Use Map vs Array
### Use Map + Reducer (for Detail Data)
### Use Map + Reducer for Detail Data
**Detail page data caching** - Cache multiple detail pages simultaneously
**Optimistic updates** - Update UI before API responds
**Per-item loading states** - Track which items are being updated
**Multiple pages open** - User can navigate between details without refetching
**Structure:**
✅ Detail page data caching multiple detail pages cached simultaneously
✅ Optimistic updates — update UI before API responds
✅ Per-item loading states — track which items are being updated
✅ Multi-page navigation — user can switch between details without refetching
```typescript
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
```
**Example:** Benchmark detail pages, Dataset detail pages, User profiles
Examples: benchmark detail pages, dataset detail pages, user profiles.
### Use Simple Array (for List Data)
### Use Simple Array for List Data
**List display** - Lists, tables, cards
**Read-only or refresh-as-whole** - Entire list refreshes together
**No per-item updates** - No need to update individual items
**Simple data flow** - Easier to understand and maintain
**Structure:**
✅ List display — lists, tables, cards
Refresh as a whole — entire list refreshes together
✅ No per-item updates — no need to mutate individual rows in place
✅ Simple data flow — fewer moving parts
```typescript
benchmarkList: AgentEvalBenchmarkListItem[]
benchmarkList: AgentEvalBenchmarkListItem[];
```
**Example:** Benchmark list, Dataset list, User list
Examples: benchmark list, dataset list, user list.
---
## State Structure Pattern
### Complete Example
```typescript
// packages/types/src/eval/benchmark.ts
import type { EvalBenchmarkRubric } from './rubric';
/**
* Full benchmark entity (for detail pages)
*/
export interface AgentEvalBenchmark {
id: string;
name: string;
description?: string | null;
identifier: string;
rubrics: EvalBenchmarkRubric[]; // Heavy field
metadata?: Record<string, unknown> | null;
isSystem: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight benchmark (for list display)
* Excludes heavy fields like rubrics
*/
export interface AgentEvalBenchmarkListItem {
id: string;
name: string;
description?: string | null;
identifier: string;
isSystem: boolean;
createdAt: Date;
// Note: rubrics excluded
// Computed statistics
testCaseCount?: number;
datasetCount?: number;
runCount?: number;
}
```
```typescript
// src/store/eval/slices/benchmark/initialState.ts
import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from '@lobechat/types';
export interface BenchmarkSliceState {
// ============================================
// List Data - Simple Array
// ============================================
/**
* List of benchmarks for list page display
* May include computed fields like testCaseCount
*/
// List — simple array
benchmarkList: AgentEvalBenchmarkListItem[];
benchmarkListInit: boolean;
// ============================================
// Detail Data - Map for Caching
// ============================================
/**
* Map of benchmark details keyed by ID
* Caches detail page data for multiple benchmarks
* Enables optimistic updates and per-item loading
*/
// Detail — map for multi-entity caching
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
loadingBenchmarkDetailIds: string[]; // per-item loading
/**
* Track which benchmark details are being loaded/updated
* For showing spinners on specific items
*/
loadingBenchmarkDetailIds: string[];
// ============================================
// Mutation States
// ============================================
// Mutation states (drive form-level UI)
isCreatingBenchmark: boolean;
isUpdatingBenchmark: boolean;
isDeletingBenchmark: boolean;
@@ -272,180 +106,51 @@ export const benchmarkInitialState: BenchmarkSliceState = {
## Reducer Pattern (for Detail Map)
### Why Use Reducer?
When the Detail Map needs optimistic updates (i.e. the user edits a row and the UI should reflect it before the server confirms), wire a typed reducer instead of inlining `set` calls. This keeps mutations testable and the dispatch surface small.
- **Immutable updates** - Immer ensures immutability
- **Type-safe actions** - TypeScript discriminated unions
- **Testable** - Pure functions easy to test
- **Reusable** - Same reducer for optimistic updates and server data
### Reducer Structure
```typescript
// src/store/eval/slices/benchmark/reducer.ts
import { produce } from 'immer';
import type { AgentEvalBenchmark } from '@lobechat/types';
// ============================================
// Action Types
// ============================================
type SetBenchmarkDetailAction = {
id: string;
type: 'setBenchmarkDetail';
value: AgentEvalBenchmark;
};
type UpdateBenchmarkDetailAction = {
id: string;
type: 'updateBenchmarkDetail';
value: Partial<AgentEvalBenchmark>;
};
type DeleteBenchmarkDetailAction = {
id: string;
type: 'deleteBenchmarkDetail';
};
export type BenchmarkDetailDispatch =
| SetBenchmarkDetailAction
| UpdateBenchmarkDetailAction
| DeleteBenchmarkDetailAction;
// ============================================
// Reducer Function
// ============================================
export const benchmarkDetailReducer = (
state: Record<string, AgentEvalBenchmark> = {},
payload: BenchmarkDetailDispatch,
): Record<string, AgentEvalBenchmark> => {
switch (payload.type) {
case 'setBenchmarkDetail': {
return produce(state, (draft) => {
draft[payload.id] = payload.value;
});
}
case 'updateBenchmarkDetail': {
return produce(state, (draft) => {
if (draft[payload.id]) {
draft[payload.id] = { ...draft[payload.id], ...payload.value };
}
});
}
case 'deleteBenchmarkDetail': {
return produce(state, (draft) => {
delete draft[payload.id];
});
}
default:
return state;
}
};
```
### Internal Dispatch Methods
```typescript
// In action.ts
export interface BenchmarkAction {
// ... other methods ...
// Internal methods - not for direct UI use
internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
}
export const createBenchmarkSlice: StateCreator<...> = (set, get) => ({
// ... other methods ...
// Internal - Dispatch to reducer
internal_dispatchBenchmarkDetail: (payload) => {
const currentMap = get().benchmarkDetailMap;
const nextMap = benchmarkDetailReducer(currentMap, payload);
// Only update if changed
if (isEqual(nextMap, currentMap)) return;
set(
{ benchmarkDetailMap: nextMap },
false,
`dispatchBenchmarkDetail/${payload.type}`,
);
},
// Internal - Update loading state
internal_updateBenchmarkDetailLoading: (id, loading) => {
set(
(state) => {
if (loading) {
return { loadingBenchmarkDetailIds: [...state.loadingBenchmarkDetailIds, id] };
}
return {
loadingBenchmarkDetailIds: state.loadingBenchmarkDetailIds.filter((i) => i !== id),
};
},
false,
'updateBenchmarkDetailLoading',
);
},
});
```
> See [`references/reducer.md`](./references/reducer.md) for the full discriminated-union action types, the `produce`-based reducer, and the `internal_dispatch*` slice methods that connect them to Zustand.
---
## Data Structure Comparison
### ❌ WRONG - Single Detail Object
### ❌ WRONG Single Detail Object
```typescript
interface BenchmarkSliceState {
// ❌ Can only cache one detail
benchmarkDetail: AgentEvalBenchmark | null;
// ❌ Global loading state
isLoadingBenchmarkDetail: boolean;
}
```
**Problems:**
Problems:
- Can only cache one detail page at a time
- Switching between details causes unnecessary refetches
- Switching between details forces refetch
- No optimistic updates
- No per-item loading states
### ✅ CORRECT - Separate List and Detail
### ✅ CORRECT Separate List and Detail
```typescript
import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from '@lobechat/types';
interface BenchmarkSliceState {
// ✅ List data - simple array
benchmarkList: AgentEvalBenchmarkListItem[];
benchmarkListInit: boolean;
// ✅ Detail data - map for caching
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
// ✅ Per-item loading
loadingBenchmarkDetailIds: string[];
// ✅ Mutation states
isCreatingBenchmark: boolean;
isUpdatingBenchmark: boolean;
isDeletingBenchmark: boolean;
}
```
**Benefits:**
Benefits:
- Cache multiple detail pages
- Fast navigation between cached details
- Optimistic updates with reducer
- Optimistic updates via reducer
- Per-item loading states
- Clear separation of concerns
@@ -455,22 +160,16 @@ interface BenchmarkSliceState {
### Accessing List Data
```typescript
```tsx
const BenchmarkList = () => {
// Simple array access
const benchmarks = useEvalStore((s) => s.benchmarkList);
const isInit = useEvalStore((s) => s.benchmarkListInit);
if (!isInit) return <Loading />;
return (
<div>
{benchmarks.map(b => (
<BenchmarkCard
key={b.id}
name={b.name}
testCaseCount={b.testCaseCount} // Computed field
/>
{benchmarks.map((b) => (
<BenchmarkCard key={b.id} name={b.name} testCaseCount={b.testCaseCount} />
))}
</div>
);
@@ -479,22 +178,18 @@ const BenchmarkList = () => {
### Accessing Detail Data
```typescript
```tsx
const BenchmarkDetail = () => {
const { benchmarkId } = useParams<{ benchmarkId: string }>();
// Get from map
const benchmark = useEvalStore((s) =>
benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined,
);
// Check loading
const isLoading = useEvalStore((s) =>
benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false,
);
if (!benchmark) return <Loading />;
return (
<div>
<h1>{benchmark.name}</h1>
@@ -510,7 +205,6 @@ const BenchmarkDetail = () => {
// src/store/eval/slices/benchmark/selectors.ts
export const benchmarkSelectors = {
getBenchmarkDetail: (id: string) => (s: EvalStore) => s.benchmarkDetailMap[id],
isLoadingBenchmarkDetail: (id: string) => (s: EvalStore) =>
s.loadingBenchmarkDetailIds.includes(id),
};
@@ -524,7 +218,7 @@ const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(bench
## Decision Tree
```
```text
Need to store data?
├─ Is it a LIST for display?
@@ -547,43 +241,40 @@ Need to store data?
When designing store state structure:
- [ ] **Organize types by entity** in separate files (e.g., `benchmark.ts`, `agentEvalDataset.ts`)
- [ ] **Organize types by entity** in separate files (e.g. `benchmark.ts`, `agentEvalDataset.ts`)
- [ ] Create **Detail** type (full entity with all fields including heavy ones)
- [ ] Create **ListItem** type:
- [ ] Subset of Detail type (exclude heavy fields)
- [ ] Subset of Detail (exclude heavy fields)
- [ ] May include computed statistics for UI
- [ ] **NOT** extending Detail type (it's a subset, not extension)
- [ ] **NOT** `extends` Detail
- [ ] Use **array** for list data: `xxxList: XxxListItem[]`
- [ ] Use **Map** for detail data: `xxxDetailMap: Record<string, Xxx>`
- [ ] Add per-item loading: `loadingXxxDetailIds: string[]`
- [ ] Create **reducer** for detail map if optimistic updates needed
- [ ] Add **internal dispatch** and **loading** methods
- [ ] Create **selectors** for clean access (optional but recommended)
- [ ] Document in comments:
- [ ] What fields are excluded from List and why
- [ ] What computed fields mean
- [ ] What each Map is for
- [ ] Per-item loading: `loadingXxxDetailIds: string[]`
- [ ] **Reducer** for detail map if optimistic updates needed (see [`references/reducer.md`](./references/reducer.md))
- [ ] **Internal dispatch** and **loading** methods
- [ ] **Selectors** for clean access (optional but recommended)
- [ ] Document in comments which fields are excluded from List and why
---
## Best Practices
1. **File organization** - One entity per file, not mixed together
2. **List is subset** - ListItem excludes heavy fields, not extends Detail
3. **Clear naming** - `xxxList` for arrays, `xxxDetailMap` for maps
4. **Consistent patterns** - All detail maps follow same structure
5. **Type safety** - Never use `any`, always use proper types
6. **Document exclusions** - Comment which fields are excluded from List and why
7. **Selectors** - Encapsulate access patterns
8. **Loading states** - Per-item for details, global for lists
9. **Immutability** - Use Immer in reducers
1. **File organization** — one entity per file, not mixed
2. **List is a subset** ListItem excludes heavy fields, does not `extends` Detail
3. **Clear naming** `xxxList` for arrays, `xxxDetailMap` for maps
4. **Consistent patterns** — all detail maps follow the same shape
5. **Type safety** — never use `any`, always use proper types
6. **Document exclusions** — comment which fields are excluded and why
7. **Selectors** — encapsulate access patterns
8. **Loading states** — per-item for details, global for mutations
9. **Immutability** — use Immer in reducers
### Common Mistakes to Avoid
**DON'T extend Detail in List:**
```typescript
// Wrong - List should not extend Detail
// Wrong — pulls heavy fields back in
export interface BenchmarkListItem extends Benchmark {
testCaseCount?: number;
}
@@ -592,7 +283,6 @@ export interface BenchmarkListItem extends Benchmark {
**DO create separate subset:**
```typescript
// Correct - List is a subset with computed fields
export interface BenchmarkListItem {
id: string;
name: string;
@@ -603,14 +293,14 @@ export interface BenchmarkListItem {
**DON'T mix entities in one file:**
```typescript
// Wrong - all entities in agentEvalEntities.ts
```text
// Wrong all entities in agentEvalEntities.ts
```
**DO separate by entity:**
```typescript
// Correct - separate files
```text
// Correct separate files
// benchmark.ts
// agentEvalDataset.ts
// agentEvalRun.ts
@@ -620,5 +310,5 @@ export interface BenchmarkListItem {
## Related Skills
- `data-fetching` - How to fetch and update this data
- `zustand` - General Zustand patterns
- `data-fetching` — how to fetch and update this data
- `zustand` — general Zustand patterns
@@ -0,0 +1,118 @@
# Reducer Pattern (for Detail Map)
## Why Use a Reducer?
- **Immutable updates** — Immer makes immutability easy
- **Type-safe actions** — discriminated union of action types prevents typos
- **Testable** — pure function, easy to unit test
- **Reusable** — same reducer powers optimistic updates and server-data writes
## Reducer Structure
```typescript
// src/store/eval/slices/benchmark/reducer.ts
import { produce } from 'immer';
import type { AgentEvalBenchmark } from '@lobechat/types';
// Action types — discriminated union
type SetBenchmarkDetailAction = {
id: string;
type: 'setBenchmarkDetail';
value: AgentEvalBenchmark;
};
type UpdateBenchmarkDetailAction = {
id: string;
type: 'updateBenchmarkDetail';
value: Partial<AgentEvalBenchmark>;
};
type DeleteBenchmarkDetailAction = {
id: string;
type: 'deleteBenchmarkDetail';
};
export type BenchmarkDetailDispatch =
| SetBenchmarkDetailAction
| UpdateBenchmarkDetailAction
| DeleteBenchmarkDetailAction;
export const benchmarkDetailReducer = (
state: Record<string, AgentEvalBenchmark> = {},
payload: BenchmarkDetailDispatch,
): Record<string, AgentEvalBenchmark> => {
switch (payload.type) {
case 'setBenchmarkDetail': {
return produce(state, (draft) => {
draft[payload.id] = payload.value;
});
}
case 'updateBenchmarkDetail': {
return produce(state, (draft) => {
if (draft[payload.id]) {
draft[payload.id] = { ...draft[payload.id], ...payload.value };
}
});
}
case 'deleteBenchmarkDetail': {
return produce(state, (draft) => {
delete draft[payload.id];
});
}
default:
return state;
}
};
```
## Internal Dispatch Methods
The slice exposes two `internal_*` methods so the reducer and the loading state stay encapsulated behind a stable contract:
```typescript
// In action.ts
export interface BenchmarkAction {
// ... other methods ...
// Internal — not for direct UI use
internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
}
export const createBenchmarkSlice: StateCreator<...> = (set, get) => ({
// ... other methods ...
// Dispatch to reducer
internal_dispatchBenchmarkDetail: (payload) => {
const currentMap = get().benchmarkDetailMap;
const nextMap = benchmarkDetailReducer(currentMap, payload);
// Skip set when nothing changed — avoids unnecessary re-renders
if (isEqual(nextMap, currentMap)) return;
set(
{ benchmarkDetailMap: nextMap },
false,
`dispatchBenchmarkDetail/${payload.type}`,
);
},
// Update loading state for a specific id
internal_updateBenchmarkDetailLoading: (id, loading) => {
set(
(state) => ({
loadingBenchmarkDetailIds: loading
? [...state.loadingBenchmarkDetailIds, id]
: state.loadingBenchmarkDetailIds.filter((i) => i !== id),
}),
false,
'updateBenchmarkDetailLoading',
);
},
});
```
The `internal_` prefix is a convention — UI components should call the public mutation methods (e.g. `updateBenchmark`), which in turn call `internal_dispatch*`. This keeps reducer dispatch shapes out of the component layer.
@@ -0,0 +1,101 @@
# Type Definitions in Detail
The skill body's Type Definitions section covers the rules; this file holds the full worked examples to keep SKILL.md lean.
## Organization
Types should be organized by entity in separate files (not mixed):
```text
@lobechat/types/src/eval/
├── benchmark.ts # Benchmark types
├── agentEvalDataset.ts # Dataset types
├── agentEvalRun.ts # Run types
└── index.ts # Re-exports
```
## Example: Benchmark Types
```typescript
// packages/types/src/eval/benchmark.ts
import type { EvalBenchmarkRubric } from './rubric';
/**
* Full benchmark entity with all fields including heavy data.
*/
export interface AgentEvalBenchmark {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
metadata?: Record<string, unknown> | null;
name: string;
referenceUrl?: string | null;
rubrics: EvalBenchmarkRubric[]; // Heavy field
updatedAt: Date;
}
/**
* Lightweight benchmark item — excludes heavy fields, may add computed stats.
*/
export interface AgentEvalBenchmarkListItem {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
name: string;
// Note: rubrics NOT included (heavy field)
// Computed statistics for UI display
datasetCount?: number;
runCount?: number;
testCaseCount?: number;
}
```
## Example: Document Types (with heavy content)
```typescript
// packages/types/src/document.ts
/**
* Full document entity — includes heavy content fields.
*/
export interface Document {
id: string;
title: string;
description?: string;
content: string; // Heavy field — full markdown content
editorData: any; // Heavy field — editor state
metadata?: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight document item — excludes heavy content.
*/
export interface DocumentListItem {
id: string;
title: string;
description?: string;
// Note: content and editorData NOT included
createdAt: Date;
updatedAt: Date;
// Computed statistics
wordCount?: number;
lastEditedBy?: string;
}
```
## Heavy Fields to Exclude from List
- Large text content (`content`, `editorData`, `fullDescription`)
- Complex objects (`rubrics`, `config`, `metrics`)
- Binary data (`image`, `file`)
- Large arrays (`messages`, `items`)
The reason these belong only on Detail: list pages render many rows, so pulling heavy fields blows up payload size and slows render. Detail pages render one entity, so the full payload is fine.
+1
View File
@@ -1,6 +1,7 @@
---
name: testing
description: Testing guide using Vitest. Use when writing tests (.test.ts, .test.tsx), fixing failing tests, improving test coverage, or debugging test issues. Triggers on test creation, test debugging, mock setup, or test-related questions.
user-invocable: false
---
# LobeHub Testing Guide
@@ -117,7 +117,7 @@ it('should handle tool calls', async () => {
toolCalls: [
{
id: 'call_123',
name: 'lobe-web-browsing____search____builtin',
name: 'lobe-web-browsing____search',
arguments: JSON.stringify({ query: 'weather' }),
},
],
+1
View File
@@ -1,6 +1,7 @@
---
name: trpc-router
description: TRPC router development guide. Use when creating or modifying TRPC routers (src/server/routers/**), adding procedures, or working with server-side API endpoints. Triggers on TRPC router creation, procedure implementation, or API endpoint tasks.
user-invocable: false
---
# TRPC Router Guide
+7 -2
View File
@@ -1,6 +1,7 @@
---
name: typescript
description: TypeScript code style and optimization guidelines. MUST READ before writing or modifying any TypeScript code (.ts, .tsx, .mts files). Also use when reviewing code quality or implementing type-safe patterns. Triggers on any TypeScript file edit, code style discussions, or type safety questions.
description: "TypeScript code style and type-safety guide for LobeHub. Read before writing or editing any `.ts` / `.tsx` / `.mts` — covers `interface` vs `type`, `Record<PropertyKey, unknown>` over `any`/`object`, `as const satisfies`, `@ts-expect-error` over `@ts-ignore`, `import type` (`separate-type-imports`), `async`/`await` + `Promise.all`, `for…of` over indexed `for`, and the no-silent-`.catch(() => fallback)` rule. Also use when reviewing type quality, deciding module augmentation (`declare module`) over `namespace`, or designing extensible types (e.g. `PipelineContext.metadata`). Triggers on any TypeScript file edit, 'fix the type', 'why is this `any`', 'should this be interface or type', 'eslint type-import', 'ts-expect-error'."
user-invocable: false
---
# TypeScript Code Style Guide
@@ -28,12 +29,16 @@ description: TypeScript code style and optimization guidelines. MUST READ before
## Imports
- This project uses `simple-import-sort/imports` and `consistent-type-imports` (`fixStyle: 'separate-type-imports'`)
- **Separate type imports**: always use `import type { ... }` for type-only imports, NOT `import { type ... }` inline syntax
- When a file already has `import type { ... }` from a package and you need to add a value import, keep them as **two separate statements**:
```ts
import type { ChatTopicBotContext } from '@lobechat/types';
import { RequestTrigger } from '@lobechat/types';
```
- Within each import statement, specifiers are sorted **alphabetically by name**
## Code Structure
@@ -42,6 +47,7 @@ description: TypeScript code style and optimization guidelines. MUST READ before
- Use consistent, descriptive naming; avoid obscure abbreviations
- Replace magic numbers/strings with well-named constants
- Defer formatting to tooling
- Prefer **named exports** over `export default` — keeps refactor renames and IDE auto-import in sync, and avoids the `default` re-naming drift you get with `import Foo from './foo'`. Reserve `export default` for files where the framework requires it (Next.js page/route/layout, React.lazy targets, config files like `vitest.config.ts`)
## UI and Theming
@@ -51,7 +57,6 @@ description: TypeScript code style and optimization guidelines. MUST READ before
## Performance
- Prefer `for…of` loops over index-based `for` loops
- Reuse existing utils in `packages/utils` or installed npm packages
- Query only required columns from database
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
# Best Practices & Common Pitfalls
Apply these once your scaffold from `implementation.md` is in place.
## Table of Contents
1. [Error Handling](#1-error-handling)
2. [Logging](#2-logging)
3. [Return Values](#3-return-values)
4. [flowControl Configuration](#4-flowcontrol-configuration)
5. [context.run() Best Practices](#5-contextrun-best-practices)
6. [Payload Validation](#6-payload-validation)
7. [Database Connection](#7-database-connection)
8. [Testing](#8-testing)
9. [Common Pitfalls](#common-pitfalls)
---
## 1. Error Handling
```typescript
export const { POST } = serve<Payload>(
async (context) => {
const { itemId } = context.requestPayload ?? {};
if (!itemId) {
return { success: false, error: 'Missing itemId in payload' };
}
try {
const result = await context.run('step-name', () => doWork(itemId));
return { success: true, itemId, result };
} catch (error) {
console.error('[workflow:error]', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
{ flowControl: { ... } },
);
```
## 2. Logging
Consistent prefixes make debugging much easier across QStash dashboards and grep:
```typescript
console.log('[{workflow}:{layer}] Starting with payload:', payload);
console.log('[{workflow}:{layer}] Processing items:', { count: items.length });
console.log('[{workflow}:{layer}] Completed:', result);
console.error('[{workflow}:{layer}:error]', error);
```
## 3. Return Values
Pick the shape that matches the layer's purpose — entry points return statistics, execution layers return per-item results.
```typescript
// Success
return { success: true, itemId, result, message: 'Optional success message' };
// Error
return { success: false, error: 'Error description', itemId };
// Statistics (entry point)
return {
success: true,
totalEligible: 100,
toProcess: 80,
alreadyProcessed: 20,
dryRun: true, // if applicable
message: 'Summary message',
};
```
## 4. flowControl Configuration
Tune concurrency by layer — entry points are singletons, execution layers fan out.
```typescript
// Layer 1: Entry — single instance to avoid duplicate processing
flowControl: { key: '{workflow}.process', parallelism: 1, ratePerSecond: 1 }
// Layer 2: Pagination — moderate concurrency
flowControl: { key: '{workflow}.paginate', parallelism: 20, ratePerSecond: 5 }
// Layer 3: Execution — higher concurrency for parallel item work
flowControl: { key: '{workflow}.execute', parallelism: 10, ratePerSecond: 5 }
```
**Why these defaults:**
- **Layer 1** always uses `parallelism: 1` so concurrent triggers don't both start the same batch.
- **Layer 2** can fan out widely (10-20) since pagination is cheap.
- **Layer 3** caps at 5-10 by default; raise/lower based on external API rate limits.
## 5. context.run() Best Practices
- Use descriptive step names with prefixes: `{workflow}:step-name`
- Each step should be idempotent (safe to retry)
- Don't nest `context.run()` calls — keep them flat
- Use unique step names when processing multiple items:
```typescript
// ✅ Unique step names
await Promise.all(
items.map((item) => context.run(`{workflow}:execute:${item.id}`, () => processItem(item))),
);
// ❌ Same step name — Upstash de-dupes by step name and you'll lose data
await Promise.all(items.map((item) => context.run(`{workflow}:execute`, () => processItem(item))));
```
## 6. Payload Validation
Validate at the top so failures are explicit, not silent `undefined` cascades:
```typescript
export const { POST } = serve<Payload>(
async (context) => {
const { itemId, configId } = context.requestPayload ?? {};
if (!itemId) return { success: false, error: 'Missing itemId in payload' };
if (!configId) return { success: false, error: 'Missing configId in payload' };
// Proceed with work...
},
{ flowControl: { ... } },
);
```
## 7. Database Connection
Get the connection once per workflow — `getServerDB()` is async, repeating it inside each step adds latency:
```typescript
export const { POST } = serve<Payload>(
async (context) => {
const db = await getServerDB();
const item = await context.run('get-item', () => itemModel.findById(db, itemId));
const result = await context.run('save-result', () => resultModel.create(db, result));
},
{ flowControl: { ... } },
);
```
## 8. Testing
Integration tests should exercise both the dry-run statistics path and the full execution path:
```typescript
describe('WorkflowName', () => {
it('should process items successfully', async () => {
const items = await createTestItems();
await WorkflowClass.triggerProcessItems({ dryRun: false });
await waitForCompletion();
const results = await getResults();
expect(results).toHaveLength(items.length);
});
it('should support dryRun mode', async () => {
const result = await WorkflowClass.triggerProcessItems({ dryRun: true });
expect(result).toMatchObject({
success: true,
dryRun: true,
totalEligible: expect.any(Number),
toProcess: expect.any(Number),
});
});
});
```
---
## Common Pitfalls
### ❌ Reusing `context.run()` step names
```typescript
// Bad — Upstash dedupes by step name
await Promise.all(items.map((item) => context.run('process', () => process(item))));
// Good
await Promise.all(items.map((item) => context.run(`process:${item.id}`, () => process(item))));
```
### ❌ Skipping payload validation
```typescript
// Bad — undefined cascades into a confusing failure later
const { itemId } = context.requestPayload ?? {};
const result = await process(itemId);
// Good — fail fast with a clear error
if (!itemId) return { success: false, error: 'Missing itemId' };
```
### ❌ Skipping the filter step
```typescript
// Bad — duplicates work for items that were already processed
const allItems = await getAllItems();
await Promise.all(allItems.map((item) => triggerExecute(item)));
// Good — keeps the pipeline idempotent
const allItems = await getAllItems();
const itemsNeedingProcessing = await filterExisting(allItems);
await Promise.all(itemsNeedingProcessing.map((item) => triggerExecute(item)));
```
### ❌ Inconsistent logging
```typescript
// Bad — different prefixes, mixed formats
console.log('Starting workflow');
log.info('Processing item:', itemId);
console.log(`Done with ${itemId}`);
// Good — uniform prefix lets you grep by workflow+layer
console.log('[workflow:layer] Starting with payload:', payload);
console.log('[workflow:layer] Processing item:', { itemId });
console.log('[workflow:layer] Completed:', { itemId, result });
```
@@ -1,6 +1,20 @@
# Cloud Project Workflow Configuration
This document covers cloud-specific workflow configurations and patterns for the lobehub-cloud project.
Cloud-specific workflow configurations and patterns for the lobehub-cloud project.
## Table of Contents
1. [Overview](#overview)
2. [Directory Structure](#directory-structure) — submodule + cloud layout
3. [Cloud-Specific Patterns](#cloud-specific-patterns) — cloud-only workflows + re-export pattern
4. [TypeScript Path Mappings](#typescript-path-mappings)
5. [Workflow Class Location](#workflow-class-location) — cloud-only vs shared
6. [Environment Variables](#environment-variables)
7. [Best Practices](#best-practices) — decide cloud vs OSS, re-export rules, naming
8. [Migration Guide](#migration-guide) — moving workflows from cloud to lobehub
9. [Examples](#examples) — `welcome-placeholder`, `agent-eval-run`
10. [Troubleshooting](#troubleshooting) — circular imports, 404s, type errors
11. [Related Documentation](#related-documentation)
## Overview
@@ -15,7 +29,7 @@ The lobehub-cloud project extends the open-source lobehub codebase with cloud-sp
### Lobehub Submodule (Open-source)
```
```text
lobehub/
└── src/
├── app/(backend)/api/workflows/
@@ -28,7 +42,7 @@ lobehub/
### Lobehub-cloud (Proprietary)
```
```text
lobehub-cloud/
└── src/
├── app/(backend)/api/workflows/
@@ -60,7 +74,7 @@ lobehub-cloud/
**Structure**:
```
```text
lobehub-cloud/src/
├── app/(backend)/api/workflows/
│ └── feature-name/
@@ -162,7 +176,7 @@ This allows cloud to override specific modules while using lobehub defaults.
Place workflow class in cloud:
```
```text
lobehub-cloud/src/server/workflows/featureName/index.ts
```
@@ -170,7 +184,7 @@ lobehub-cloud/src/server/workflows/featureName/index.ts
Place workflow class in lobehub, re-export in cloud if needed:
```
```text
lobehub/src/server/workflows/featureName/index.ts
```
@@ -245,7 +259,7 @@ For shared features:
Follow consistent naming across lobehub and cloud:
```
```text
# Both should use same structure
lobehub/src/app/(backend)/api/workflows/feature-name/
lobehub-cloud/src/app/(backend)/api/workflows/feature-name/
@@ -306,7 +320,7 @@ import { Workflow } from 'lobehub/src/server/workflows/feature';
**Structure**:
```
```text
lobehub-cloud/
├── src/app/(backend)/api/workflows/welcome-placeholder/
│ ├── process-users/route.ts
@@ -0,0 +1,91 @@
# Worked Examples
Two real workflows already in the codebase that follow this skill's pattern verbatim. Skim them when you want to see the pattern applied to concrete entities.
## Example 1: Welcome Placeholder
**Use case:** Generate AI-powered welcome placeholders for users.
**Structure:**
- Layer 1: `process-users` — entry point, checks eligible users
- Layer 2: `paginate-users` — paginates through active users
- Layer 3: `generate-user` — generates placeholders for ONE user
**Key features:**
- Filters users who already have cached placeholders in Redis
- `paidOnly` flag to scope to subscribed users
- `dryRun` mode for statistics
- Fan-out for large user batches (`CHUNK_SIZE=20`)
**Layer 3 shape:**
```typescript
export const { POST } = serve<GenerateUserPlaceholderPayload>(async (context) => {
const { userId } = context.requestPayload ?? {};
const workflow = new WelcomePlaceholderWorkflow(db, userId);
const placeholders = await context.run('generate', () => workflow.generate());
return { success: true, userId, placeholdersCount: placeholders.length };
});
```
**Files:**
- `/api/workflows/welcome-placeholder/process-users/route.ts`
- `/api/workflows/welcome-placeholder/paginate-users/route.ts`
- `/api/workflows/welcome-placeholder/generate-user/route.ts`
- `/server/workflows/welcomePlaceholder/index.ts`
---
## Example 2: Agent Welcome
**Use case:** Generate welcome messages and open questions for AI agents.
**Structure:**
- Layer 1: `process-agents` — entry point, checks eligible agents
- Layer 2: `paginate-agents` — paginates through active agents
- Layer 3: `generate-agent` — generates welcome data for ONE agent
**Key features:**
- Filters agents who already have cached data in Redis
- `paidOnly` flag for subscribed users' agents only
- `dryRun` mode for statistics
- Fan-out for large agent batches (`CHUNK_SIZE=20`)
**Layer 3 shape:**
```typescript
export const { POST } = serve<GenerateAgentWelcomePayload>(async (context) => {
const { agentId } = context.requestPayload ?? {};
const workflow = new AgentWelcomeWorkflow(db, agentId);
const data = await context.run('generate', () => workflow.generate());
return { success: true, agentId, data };
});
```
**Files:**
- `/api/workflows/agent-welcome/process-agents/route.ts`
- `/api/workflows/agent-welcome/paginate-agents/route.ts`
- `/api/workflows/agent-welcome/generate-agent/route.ts`
- `/server/workflows/agentWelcome/index.ts`
---
## What's identical, what differs
Both workflows are the **same pattern** — they only differ in:
- Entity type (users vs agents)
- Business logic (placeholder generation vs welcome generation)
- Data source (different database queries)
Everything else — the 3-layer split, dry-run handling, fan-out, filter-existing, flowControl tuning — is identical. That's the whole point: once you internalize the pattern, adding a new workflow is mostly entity-substitution.
@@ -0,0 +1,333 @@
# Implementation Patterns
Full code templates for the 3-layer architecture. Read this when actually writing workflow files.
## Table of Contents
1. [Workflow Class](#workflow-class) — `src/server/workflows/{workflowName}/index.ts`
2. [Layer 1: Entry Point](#layer-1-entry-point-process-) — `process-*` route
3. [Layer 2: Pagination](#layer-2-pagination-paginate-) — `paginate-*` route
4. [Layer 3: Execution](#layer-3-execution-execute--generate-) — `execute-*` / `generate-*` route
---
## Workflow Class
**Location:** `src/server/workflows/{workflowName}/index.ts`
```typescript
import { Client } from '@upstash/workflow';
import debug from 'debug';
const log = debug('lobe-server:workflows:{workflow-name}');
// Workflow paths
const WORKFLOW_PATHS = {
processItems: '/api/workflows/{workflow-name}/process-items',
paginateItems: '/api/workflows/{workflow-name}/paginate-items',
executeItem: '/api/workflows/{workflow-name}/execute-item',
} as const;
// Payload types
export interface ProcessItemsPayload {
dryRun?: boolean;
force?: boolean;
}
export interface PaginateItemsPayload {
cursor?: string;
itemIds?: string[]; // For fanout chunks
}
export interface ExecuteItemPayload {
itemId: string;
}
const getWorkflowUrl = (path: string): string => {
const baseUrl = process.env.APP_URL;
if (!baseUrl) throw new Error('APP_URL is required to trigger workflows');
return new URL(path, baseUrl).toString();
};
const getWorkflowClient = (): Client => {
const token = process.env.QSTASH_TOKEN;
if (!token) throw new Error('QSTASH_TOKEN is required to trigger workflows');
const config: ConstructorParameters<typeof Client>[0] = { token };
if (process.env.QSTASH_URL) {
(config as Record<string, unknown>).url = process.env.QSTASH_URL;
}
return new Client(config);
};
export class {WorkflowName}Workflow {
private static client: Client;
private static getClient(): Client {
if (!this.client) this.client = getWorkflowClient();
return this.client;
}
static triggerProcessItems(payload: ProcessItemsPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.processItems);
log('Triggering process-items workflow');
return this.getClient().trigger({ body: payload, url });
}
static triggerPaginateItems(payload: PaginateItemsPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.paginateItems);
log('Triggering paginate-items workflow');
return this.getClient().trigger({ body: payload, url });
}
static triggerExecuteItem(payload: ExecuteItemPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.executeItem);
log('Triggering execute-item workflow: %s', payload.itemId);
return this.getClient().trigger({ body: payload, url });
}
/**
* Filter items that need processing (e.g. check Redis cache, database state).
* Return only the ones that actually need work — keeps the pipeline idempotent.
*/
static async filterItemsNeedingProcessing(itemIds: string[]): Promise<string[]> {
if (itemIds.length === 0) return [];
// Check existing state and return items that need processing
return itemIds;
}
}
```
---
## Layer 1: Entry Point (process-\*)
**Purpose:** Validates prerequisites, calculates statistics, supports dry-run mode.
```typescript
import { serve } from '@upstash/workflow/nextjs';
import { getServerDB } from '@/database/server';
import { WorkflowClass, type ProcessPayload } from '@/server/workflows/{workflowName}';
export const { POST } = serve<ProcessPayload>(
async (context) => {
const { dryRun, force } = context.requestPayload ?? {};
console.log('[{workflow}:process] Starting with payload:', { dryRun, force });
const allItemIds = await context.run('{workflow}:get-all-items', async () => {
const db = await getServerDB();
// Query database for eligible items
return items.map((item) => item.id);
});
console.log('[{workflow}:process] Total eligible items:', allItemIds.length);
if (allItemIds.length === 0) {
return { success: true, totalEligible: 0, message: 'No eligible items found' };
}
const itemsNeedingProcessing = await context.run('{workflow}:filter-existing', () =>
WorkflowClass.filterItemsNeedingProcessing(allItemIds),
);
const result = {
success: true,
totalEligible: allItemIds.length,
toProcess: itemsNeedingProcessing.length,
alreadyProcessed: allItemIds.length - itemsNeedingProcessing.length,
};
// Dry-run short-circuits before any side effects
if (dryRun) {
console.log('[{workflow}:process] Dry run mode, returning statistics only');
return {
...result,
dryRun: true,
message: `[DryRun] Would process ${itemsNeedingProcessing.length} items`,
};
}
if (itemsNeedingProcessing.length === 0) {
return { ...result, message: 'All items already processed' };
}
await context.run('{workflow}:trigger-paginate', () => WorkflowClass.triggerPaginateItems({}));
return {
...result,
message: `Triggered pagination for ${itemsNeedingProcessing.length} items`,
};
},
{
flowControl: {
key: '{workflow}.process',
parallelism: 1, // single instance — avoids duplicate processing
ratePerSecond: 1,
},
},
);
```
---
## Layer 2: Pagination (paginate-\*)
**Purpose:** Handles cursor-based pagination, implements fan-out for large batches.
```typescript
import { serve } from '@upstash/workflow/nextjs';
import { chunk } from 'es-toolkit/compat';
import { getServerDB } from '@/database/server';
import { WorkflowClass, type PaginatePayload } from '@/server/workflows/{workflowName}';
const PAGE_SIZE = 50;
const CHUNK_SIZE = 20;
export const { POST } = serve<PaginatePayload>(
async (context) => {
const { cursor, itemIds: payloadItemIds } = context.requestPayload ?? {};
console.log('[{workflow}:paginate] Starting:', {
cursor,
itemIdsCount: payloadItemIds?.length ?? 0,
});
// If specific itemIds were passed in (from a fanout chunk), process them directly
if (payloadItemIds && payloadItemIds.length > 0) {
await Promise.all(
payloadItemIds.map((itemId) =>
context.run(`{workflow}:execute:${itemId}`, () =>
WorkflowClass.triggerExecuteItem({ itemId }),
),
),
);
return { success: true, processedItems: payloadItemIds.length };
}
// Paginate through all items
const itemBatch = await context.run('{workflow}:get-batch', async () => {
const db = await getServerDB();
const items = await db.query(...);
if (!items.length) return { ids: [] };
const last = items.at(-1);
return {
ids: items.map((item) => item.id),
cursor: last ? last.id : undefined,
};
});
const batchItemIds = itemBatch.ids;
const nextCursor = 'cursor' in itemBatch ? itemBatch.cursor : undefined;
if (batchItemIds.length === 0) {
return { success: true, message: 'Pagination complete' };
}
const itemIds = await context.run('{workflow}:filter-existing', () =>
WorkflowClass.filterItemsNeedingProcessing(batchItemIds),
);
if (itemIds.length > 0) {
if (itemIds.length > CHUNK_SIZE) {
// Fan out — recursively re-enter pagination with each chunk
const chunks = chunk(itemIds, CHUNK_SIZE);
console.log('[{workflow}:paginate] Fanout mode:', {
chunks: chunks.length,
chunkSize: CHUNK_SIZE,
});
await Promise.all(
chunks.map((ids, idx) =>
context.run(`{workflow}:fanout:${idx + 1}/${chunks.length}`, () =>
WorkflowClass.triggerPaginateItems({ itemIds: ids }),
),
),
);
} else {
// Process this page directly
await Promise.all(
itemIds.map((itemId) =>
context.run(`{workflow}:execute:${itemId}`, () =>
WorkflowClass.triggerExecuteItem({ itemId }),
),
),
);
}
}
// Tail-call into the next page
if (nextCursor) {
await context.run('{workflow}:next-page', () =>
WorkflowClass.triggerPaginateItems({ cursor: nextCursor }),
);
}
return {
success: true,
processedItems: itemIds.length,
skippedItems: batchItemIds.length - itemIds.length,
nextCursor: nextCursor ?? null,
};
},
{
flowControl: {
key: '{workflow}.paginate',
parallelism: 20,
ratePerSecond: 5,
},
},
);
```
---
## Layer 3: Execution (execute-\* / generate-\*)
**Purpose:** Performs the actual business logic for exactly ONE item.
```typescript
import { serve } from '@upstash/workflow/nextjs';
import { getServerDB } from '@/database/server';
import { WorkflowClass, type ExecutePayload } from '@/server/workflows/{workflowName}';
export const { POST } = serve<ExecutePayload>(
async (context) => {
const { itemId } = context.requestPayload ?? {};
if (!itemId) {
return { success: false, error: 'Missing itemId' };
}
const db = await getServerDB();
const item = await context.run('{workflow}:get-item', async () => {
// Query database for item
return item;
});
if (!item) {
return { success: false, error: 'Item not found' };
}
const result = await context.run('{workflow}:process-item', async () => {
const workflow = new WorkflowClass(db, itemId);
return workflow.generate(); // or process(), execute(), etc.
});
await context.run('{workflow}:save-result', async () => {
const workflow = new WorkflowClass(db, itemId);
return workflow.saveToRedis(result); // or saveToDatabase(), etc.
});
return { success: true, itemId, result };
},
{
flowControl: {
key: '{workflow}.execute',
parallelism: 10,
ratePerSecond: 5,
},
},
);
```
+21 -239
View File
@@ -1,10 +1,14 @@
---
name: version-release
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. This skill is for release process and GitHub Release notes (not docs/changelog page writing)."
disable-model-invocation: true
argument-hint: '[minor|patch] [version?]'
---
# Version Release Workflow
This skill is a router. The detailed steps live in `references/`.
## Scope Boundary (Important)
This skill is only for:
@@ -28,68 +32,12 @@ The primary development branch is **canary**. All day-to-day development happens
Only two release types are used in practice (major releases are extremely rare and can be ignored):
| Type | Use Case | Frequency | Source Branch | PR Title Format | Version |
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- |
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set |
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 |
| Type | Use Case | Frequency | Source Branch | PR Title Format | Version | Reference |
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- | --------------------------------------- |
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set | `references/minor-release.md` |
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 | `references/patch-release-scenarios.md` |
## Minor Release Workflow
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks.
### Steps
1. **Create a release branch from canary**
```bash
git checkout canary
git pull origin canary
git checkout -b release/v{version}
git push -u origin release/v{version}
```
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. 2.1.x -> 2.2.0)
3. **Create a PR to main**
```bash
gh pr create \
--title "🚀 release: v{version}" \
--base main \
--head release/v{version} \
--body "## 📦 Release v{version} ..."
```
> \[!IMPORTANT]
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
4. **Automatic trigger after merge**: `auto-tag-release` detects the title format and uses the version number from the title to complete the release.
### Scripts
```bash
bun run release:branch # Interactive
bun run release:branch --minor # Directly specify minor
```
## Patch Release Workflow
Version number is automatically bumped by patch +1. There are 4 common scenarios:
| Scenario | Source Branch | Branch Naming | Description |
| ------------------- | ------------- | ----------------------------- | ------------------------------------------------ |
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary -> main |
| Bug Hotfix | main | `hotfix/v{version}-{hash}` | Emergency bug fix |
| New Model Launch | canary | Community PR merged directly | New model launch, triggered by PR title prefix |
| DB Schema Migration | main | `release/db-migration-{name}` | Database migration, requires dedicated changelog |
All scenarios auto-bump patch +1. Patch PR titles do not need a version number. See `reference/patch-release-scenarios.md` for detailed steps per scenario.
### Scripts
```bash
bun run hotfix:branch # Hotfix scenario
```
For writing the release-note body (any release type), see `references/release-notes-style.md`.
## Auto-Release Trigger Rules (`auto-tag-release.yml`)
@@ -127,7 +75,7 @@ PRs that don't match any conditions above (e.g. `docs`, `chore`, `ci`, `test`) w
When the user requests a release:
### Precheck
### Precheck (applies to all release types)
Before creating the release branch, verify the source branch:
@@ -135,184 +83,18 @@ Before creating the release branch, verify the source branch:
- **All other release/hotfix branches**: must branch from `main`; run `git merge-base --is-ancestor main <branch> && echo OK`
- If the branch is based on the wrong source, recreate from the correct base
### Minor Release
### Routing
1. Read `package.json` to get the current version and compute the next minor version
2. Create a `release/v{version}` branch from canary
3. Push and create PR — **title must be `🚀 release: v{version}`**
4. Inform the user that merge will auto-trigger release
Pick the right reference and follow it end-to-end:
### Patch Release
- **Minor release** → `references/minor-release.md`
- **Patch release** (weekly / hotfix / model launch / DB migration) → `references/patch-release-scenarios.md`
- **Writing the PR body / release notes** (any release type) → `references/release-notes-style.md`
Choose workflow by scenario (see `reference/patch-release-scenarios.md`):
### Hard Rules (apply to every release type)
- **Weekly Release**: create `release/weekly-{YYYYMMDD}` from canary; use `git log main..canary` for release note inputs; title like `🚀 release: 20260222`
- **Bug Hotfix**: create `hotfix/` from main; use gitmoji prefix title (e.g. `🐛 fix: ...`)
- **New Model Launch**: community PRs trigger automatically via title prefix (`feat` / `style`)
- **DB Migration**: create `release/db-migration-{name}` from main; cherry-pick migration commits; include dedicated migration notes
### Hard Rules
- **Do NOT** manually modify `package.json` version
- **Do NOT** manually create tags
- Minor PR title format is strict
- Patch PRs do not need explicit version number
- Keep release facts accurate; do not invent metrics or availability statements
## GitHub Release Changelog Standard (Long-Form Style)
Use this section for writing **GitHub Release notes** (or release PR body when the PR body is intended to become release notes).\
Do not use this as `docs/changelog` page guidance.
### Positioning
This release-note style is:
1. **Data-backed at the top** (date, range, key metrics)
2. **Narrative first, then structured detail**
3. **Deep but scannable** (clear sectioning + compact bullets)
4. **Contributor-forward** (credits are part of the release story)
### Required Inputs Before Writing
Collect these inputs first:
1. Compare range (`<prev_tag>...<current_tag>`)
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
4. Contributor list (with standout contributions if known)
5. Known risks / migrations / rollout notes (if any)
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
### Canonical Structure
Follow this section order unless the user asks otherwise:
1. `# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)`
2. Metadata lines:
- `Release Date`
- `Since <Previous Version>` metrics
3. One quoted release thesis (single paragraph, 1-2 lines)
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
5. Domain blocks with optional `###` subsections:
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
- `## 📱 Platforms / Integrations`
- `## 🖥️ CLI & User Experience`
- `## 🔧 Tooling`
- `## 🔒 Security & Reliability`
- `## 📚 Documentation` (optional if meaningful)
6. `## 👥 Contributors`
7. `**Full Changelog**: <prev>...<current>`
Use `---` separators between major blocks for long releases.
### Writing Rules (Hard)
1. **No fabricated metrics**: all numbers must be traceable.
2. **No vague headline bullets**: each bullet must include capability + impact.
3. **No internal-only framing**: phrase from user/operator perspective.
4. **Security must be explicit** when security-sensitive fixes are present.
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
6. **Terminology consistency**: same feature/provider name across sections.
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
### Style Rules (Long-Form)
1. Start with an "everyday use" framing, not implementation internals.
2. Mix narrative sentence + evidence bullets.
3. Keep bullets compact but informative:
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
4. Use bold only for capability names, not for whole sentences.
5. Keep heading depth <= 3 levels.
### Release Size Heuristics
- **Minor / major milestone release**
- Include full structure with multiple domain blocks.
- `Highlights` usually 8-12 bullets.
- **Weekly patch release**
- Keep full skeleton but reduce subsection count.
- `Highlights` usually 4-8 bullets.
- **DB migration release**
- Keep concise.
- Must include `Migration overview`, operator impact, and rollback/backup note.
### GitHub Release Changelog Template
```md
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
**Release Date:** <Month DD, YYYY>
**Since <Previous Version>:** <N commits> · <N merged PRs> · <N resolved issues> · <N contributors>
> <One release thesis sentence: what this release unlocks in practice.>
---
## ✨ Highlights
- **<Capability A>** — <What changed and why it matters>. (#1234)
- **<Capability B>** — <What changed and why it matters>. (#2345)
- **<Capability C>** — <What changed and why it matters>. (#3456)
---
## 🏗️ Core Product & Architecture
### <Subdomain>
- <Concrete change + impact>. (#...)
- <Concrete change + impact>. (#...)
---
## 📱 Platforms / Integrations
- <Platform update + impact>. (#...)
- <Compatibility/reliability fix + impact>. (#...)
---
## 🖥️ CLI & User Experience
- <User-facing workflow improvement>. (#...)
- <Quality-of-life fix>. (#...)
---
## 🔧 Tooling
- <Tool/runtime improvement>. (#...)
---
## 🔒 Security & Reliability
- **Security:** <hardening or vulnerability fix>. (#...)
- **Reliability:** <stability/performance behavior improvement>. (#...)
---
## 👥 Contributors
**<N merged PRs>** from **<N contributors>** across **<N commits>**.
### Community Contributors
- @<username> - <notable contribution area>
- @<username> - <notable contribution area>
---
**Full Changelog**: <previous_tag>...<current_tag>
```
### Quick Checklist
- [ ] Uses top metadata and a clear release thesis
- [ ] Includes `Highlights` plus domain-grouped sections
- [ ] Every major bullet states both change and user/operator impact
- [ ] Security and reliability updates are explicitly surfaced (when present)
- [ ] Contributor credits and compare range are included
- [ ] All numbers and claims are verifiable
- **Do NOT** manually modify `package.json` version — CI handles it.
- **Do NOT** manually create tags — CI handles them.
- Minor PR title format is strict (`🚀 release: v{x.y.z}`).
- Patch PRs do not need an explicit version number.
- Keep release facts accurate; do not invent metrics or availability statements. Release-note inputs (compare base, PR refs, contributor list) **must be derived from `git`** per `references/release-notes-style.md` § Computing Inputs — never from memory or descriptions.
@@ -1,4 +1,4 @@
# 🚀 LobeHub v2.1.50 (20260416)
# 🚀 LobeHub Release (20260416)
**Release Date:** April 20, 2026\
**Migration Scope:** Agent benchmark data model bootstrap (5 new tables, 2 new indexes)
@@ -7,14 +7,6 @@
---
## ✨ Highlights
- **Benchmark Lifecycle Schema** — Added a relational model that tracks benchmark setup, runs, per-topic execution, and record outputs end-to-end.
- **Queryability Upgrade** — Added indexes for run status and benchmark-topic joins, improving operational queries in dashboard and debugging workflows.
- **Safer Operator Rollout** — Migration is startup-driven and backward-compatible with existing non-benchmark chat workflows.
---
## 🗄️ Migration Overview
Added tables:
@@ -0,0 +1,21 @@
# 🚀 LobeHub Release (20260427)
**Hotfix Scope:** Agent topic-switching regression — stale chat state on agent change
> Clears residual topic state when navigating between agents and restores blank-canvas behavior on agent switch.
## 🐛 What's Fixed
- **Stale topic on agent switch** — Switching from `/agent/agt_A/tpc_X` to `/agent/agt_B` no longer leaves the previous topic's messages on screen, and _Start new topic_ responds again. (#14231)
- **Header & sidebar consistency** — Conversation header now shows the active subtopic's title, and the sidebar keeps the parent topic's thread list expanded while a thread is open.
## ⚙️ Upgrade
- Self-hosted: pull the new image and restart. No schema or env changes.
- Cloud: applied automatically.
## 👥 Owner
@{pr-author}
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
@@ -1,7 +1,7 @@
# 🚀 LobeHub v2.1.50 (20260420)
# 🚀 LobeHub Release (20260420)
**Release Date:** April 20, 2026\
**Since v2026.04.13:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
**Since previous release:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
> This weekly release focuses on reducing friction in everyday agent work: faster model routing, smoother gateway behavior, stronger task continuity, and clearer operator diagnostics when something goes wrong.
@@ -77,4 +77,4 @@
---
**Full Changelog**: v2026.04.13...v2026.04.20
**Full Changelog**: <previous-tag>...<current-tag>
@@ -0,0 +1,47 @@
# Minor Release Workflow
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks. The PR title carries the exact version number; CI parses it to drive the rest of the release.
## Steps
1. **Create a release branch from canary**
```bash
git checkout canary
git pull origin canary
git checkout -b release/v{version}
git push -u origin release/v{version}
```
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. `2.1.x` → `2.2.0`).
3. **Create a PR to main**
```bash
gh pr create \
--title "🚀 release: v{version}" \
--base main \
--head release/v{version} \
--body-file release_body.md
```
> \[!IMPORTANT]
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
4. **Write the PR body as release notes** — Follow `release-notes-style.md`. Compare base is the latest semver tag on main (`git describe --tags --abbrev=0 origin/main`).
5. **Automatic trigger after merge** — `auto-tag-release` detects the title format, uses the version number from the title, bumps `package.json`, tags `v{x.y.z}`, creates the GitHub Release, and dispatches `sync-main-to-canary`.
## Scripts
```bash
bun run release:branch # Interactive
bun run release:branch --minor # Directly specify minor
```
## Hard Rules (specific to Minor)
- PR title format is **strict**: `🚀 release: v{x.y.z}`. Any deviation falls through to patch detection.
- Do **NOT** manually modify `package.json` version — CI will bump it.
- Do **NOT** manually create the tag — CI will tag.
- Highlights bullet count is usually 812 (see `release-notes-style.md` size heuristics).
@@ -21,12 +21,16 @@ git push -u origin release/weekly-{YYYYMMDD}
2. **Scan changes and write changelog**
Compute the previous tag from main first — never reuse the last weekly's tag, since hotfixes published in between will be missed:
```bash
git log main..canary --oneline
git diff main...canary --stat
git fetch origin main canary --tags
PREV_TAG=$(git describe --tags --abbrev=0 origin/main --match 'v*.*.*' --exclude '*-canary*' --exclude '*-nightly*')
git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --oneline --no-merges
git diff "$PREV_TAG...origin/release/weekly-{YYYYMMDD}" --stat
```
Write a user-facing changelog following the format in `patch-release-changelog-example.md`.
Then follow `./release-notes-style.md` § **Computing Inputs (Hard Rules)** to derive PR refs, metrics, and contributors. Every `(#XXXX)` in the body must come from actual commit subjects in this range — never inferred from descriptions.
3. **Create PR to main** with the changelog as the PR body
@@ -59,7 +63,10 @@ git push -u origin hotfix/v{version}-{short-hash}
2. **Create PR to main** with a gitmoji prefix title (e.g. `🐛 fix: description`)
3. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
3. **Write a short hotfix changelog** — See `changelog-example/hotfix.md`. Keep it minimal: scope line, 1-3 fix bullets (symptom + fix in one sentence), upgrade note, owner. No long root-cause section — that lives in the commit message.
- **Hotfix owner**: Use the actual PR author (retrieve via `gh pr view <number> --json author --jq '.author.login'`), never hardcode a username.
4. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
### Script
@@ -0,0 +1,330 @@
# GitHub Release Changelog Standard (Long-Form Style)
Use this guide for **GitHub Release notes** — the body of a release PR that becomes the GitHub Release after merge. Do **not** use it for `docs/changelog/*.mdx` website pages (load `../../docs-changelog/SKILL.md` instead).
## Table of Contents
1. [Positioning](#positioning) — what this style optimizes for
2. [Required Inputs Before Writing](#required-inputs-before-writing)
3. [Computing Inputs (Hard Rules — Verify, Never Guess)](#computing-inputs-hard-rules--verify-never-guess) — base ref, PR refs, metrics, authors, pre-publish verification
4. [Canonical Structure (Long-Form: Minor / Weekly)](#canonical-structure-long-form-minor--weekly)
5. [Variants for Shorter Releases](#variants-for-shorter-releases) — hotfix, DB migration
6. [Writing Rules (Hard)](#writing-rules-hard)
7. [Style Rules (Long-Form)](#style-rules-long-form)
8. [Release Size Heuristics](#release-size-heuristics) — when to use which variant
9. [Contributor Ordering](#contributor-ordering)
10. [Template](#template) — copy-paste skeleton
11. [Quick Checklist](#quick-checklist) — long-form + hotfix
## Positioning
This release-note style is:
1. **Data-backed at the top** (date, range, key metrics)
2. **Narrative first, then structured detail**
3. **Deep but scannable** (clear sectioning + compact bullets)
4. **Contributor-forward** (credits are part of the release story)
## Required Inputs Before Writing
Collect these inputs first:
1. Compare range (`<prev_tag>...<current_tag>`)
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
4. Contributor list (with standout contributions if known)
5. Known risks / migrations / rollout notes (if any)
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
## Computing Inputs (Hard Rules — Verify, Never Guess)
> Hallucinated PR numbers and wrong "Since v..." bases are the #1 failure mode of this skill. Every number and every `(#XXXX)` must come from `git`, never from memory or inference.
### 1. Compare base = latest semver tag on `main`
Do **not** eyeball the tag list or pick the "last weekly" PR. Compute it:
```bash
git fetch origin main canary --tags
PREV_TAG=$(git describe --tags --abbrev=0 origin/main --match 'v*.*.*' --exclude '*-canary*' --exclude '*-nightly*')
echo "$PREV_TAG"
```
Sanity check that the tag is reachable from the release branch:
```bash
git merge-base --is-ancestor "$PREV_TAG" origin/release/weekly-{YYYYMMDD} && echo OK
```
If the check fails, stop and ask the user — the release branch is based on the wrong source.
> **Why not "the last weekly release PR"?** Hotfixes (`v2.1.54`, `v2.1.55`, …) merge directly into main between weeklies. They get back-merged via `sync-main-to-canary`, so the latest semver tag on main _is_ the correct previous release for both weekly and minor flows. Picking the previous weekly's tag will silently undercount and put a stale version in "Since v…".
### 2. PR refs must come from commit subjects — never from descriptions
Compute the canonical set:
```bash
git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" \
--pretty=format:'%s' --no-merges \
| grep -oE '\(#[0-9]+\)$' \
| sort -u > /tmp/release_prs.txt
```
Hard rules:
- Every `(#XXXX)` you write in the body **must** appear in `/tmp/release_prs.txt`. No exceptions.
- Never infer a PR number from a feature description. If you remember "the KB BM25 PR was around #14501", that memory is wrong about half the time. Look up the commit hash by feature keyword and read its actual subject.
- If your terminal truncates long subjects (any wrapper that compresses output, e.g. `rtk`), bypass it. With `rtk` use `rtk proxy git log …`. Verify with `wc -l /tmp/release_prs.txt` — the count must match `git log $PREV_TAG..HEAD --no-merges --pretty=format:'%h' | wc -l` minus the few commits without a PR ref. A mismatch of >5% means subjects are being silently truncated.
### 3. Metrics must come from git counts
```bash
PR_COUNT=$(wc -l < /tmp/release_prs.txt | tr -d ' ')
COMMIT_COUNT=$(git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --no-merges --pretty=format:'%h' | wc -l | tr -d ' ')
CONTRIBUTOR_COUNT=$(git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --no-merges --pretty=format:'%an' \
| sort -u \
| grep -viE '^(lobehubbot|LobeHub Bot|renovate\[bot\])$' \
| wc -l | tr -d ' ')
```
If a number cannot be confidently derived, omit it — never guess.
### 4. Author-to-handle resolution
Git `%an` is the commit author display name, not the GitHub handle. For each author you mention, confirm the handle:
```bash
gh pr view "$PR_NUMBER" --repo lobehub/lobe-chat --json author --jq '.author.login'
```
Use the result for `@handle`. Then classify each author per the `LobeHub team roster` below; community first, team after.
### 5. Pre-publish verification (mandatory)
Before `gh pr create` / `gh pr edit --body-file`, diff body PR refs against the canonical set:
```bash
grep -oE '#[0-9]+' release_body.md | sort -u > /tmp/body_prs.txt
sed 's/[()]//g' /tmp/release_prs.txt > /tmp/release_prs_clean.txt
echo "=== In body but NOT in actual range (must be EMPTY) ==="
comm -23 /tmp/body_prs.txt /tmp/release_prs_clean.txt
```
Empty diff = OK. Any output = the body cites a PR that wasn't merged in this range. Stop and fix before publishing.
Also verify the metrics line in the body matches the computed values (`PR_COUNT`, `CONTRIBUTOR_COUNT`) and that `**Full Changelog**` uses `$PREV_TAG`, not some older tag.
## Canonical Structure (Long-Form: Minor / Weekly)
Follow this section order for **Minor** and **Weekly** releases unless the user asks otherwise. For **Hotfix** and **DB Migration**, see § Variants for Shorter Releases below — the canonical structure does not apply.
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
2. Metadata lines:
- `Release Date`
- `Since <Previous Version>` metrics
3. One quoted release thesis (single paragraph, 1-2 lines)
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
5. Domain blocks with optional `###` subsections:
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
- `## 📱 Platforms / Integrations`
- `## 🖥️ CLI & User Experience`
- `## 🔧 Tooling`
- `## 🔒 Security & Reliability`
- `## 📚 Documentation` (optional if meaningful)
6. `## 👥 Contributors`
7. `**Full Changelog**: <prev>...<current>`
Use `---` separators between major blocks for long releases.
## Variants for Shorter Releases
The Canonical Structure above is for **long-form** (Minor / Weekly). Two short-form variants override it.
### Hotfix Variant
A hotfix targets one regression and ships fast. The body is short and operator-focused — no Highlights, no domain blocks, no Contributors line.
Required sections, in order:
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
2. `**Hotfix Scope:**` — one line summarizing the regression scope (e.g. `Agent topic-switching regression — stale chat state on agent change`). Replaces the long-form `Release Date` / `Since vX.Y.Z` metrics.
3. One quoted thesis (single paragraph, 1-2 lines) describing what is now restored.
4. `## 🐛 What's Fixed` — 1-3 bullets, each `**<symptom>** — <fix in one sentence>. (#PR)`. No root-cause prose; that lives in the commit message.
5. `## ⚙️ Upgrade` — short notes for self-hosted (pull image / restart, schema or env changes) and cloud (usually "applied automatically").
6. `## 👥 Owner` — single `@handle` for the PR author, resolved via `gh pr view "$PR" --json author --jq '.author.login'`. Never hardcoded.
Hard rules specific to hotfix:
- **No Highlights / domain blocks / Contributors / Full Changelog** — these add noise to a one-shot fix.
- **No metric line** — `Since vX.Y.Z` doesn't apply; the body cites the single PR (or 1-3 PRs) directly.
- **Owner ≠ Contributors** — one author, listed under § Owner. Not a flat handle list.
- See `changelog-example/hotfix.md` for the canonical template.
### DB Migration Variant
Database schema changes that need to be released independently. Operator impact is the headline.
Required sections, in order:
1. `# 🚀 LobeHub Release (<YYYYMMDD>)` + scope line
2. **Migration overview** — what tables / columns are added, modified, or removed
3. **Operator impact** — backwards-compatible? required actions for self-hosted?
4. **Rollback / backup note** — how to recover
5. `## 👥 Owner` — single PR author, resolved via `gh pr view`
See `changelog-example/db-migration.md` for the canonical template.
## Writing Rules (Hard)
1. **No fabricated metrics**: all numbers must be traceable.
2. **No vague headline bullets**: each bullet must include capability + impact.
3. **No internal-only framing**: phrase from user/operator perspective.
4. **Security must be explicit** when security-sensitive fixes are present.
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
6. **Terminology consistency**: same feature/provider name across sections.
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
## Style Rules (Long-Form)
1. Start with an "everyday use" framing, not implementation internals.
2. Mix narrative sentence + evidence bullets.
3. Keep bullets compact but informative:
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
4. Use bold only for capability names, not for whole sentences.
5. Keep heading depth ≤ 3 levels.
## Release Size Heuristics
- **Minor / major milestone release**
- Long-form structure with multiple domain blocks.
- `Highlights` usually 8-12 bullets.
- **Weekly patch release**
- Long-form skeleton with reduced subsection count.
- `Highlights` usually 4-8 bullets.
- **Hotfix release**
- Short-form (see § Variants → Hotfix). No Highlights, no domain blocks, no Contributors.
- 1-3 fix bullets. Body should fit on one screen.
- **DB migration release**
- Short-form (see § Variants → DB Migration).
- Must include `Migration overview`, operator impact, and rollback/backup note.
## Contributor Ordering
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
- @arvinxx
- @Innei
- @tjx666 (commit author name: YuTengjing)
- @LiJian
- @Neko
- @Rdmclin2
- @AmAzing129
- @sudongyuer (commit author name: Tsuki)
- @rivertwilight (commit author name: René Wang)
- @CanisMinor
- @cy948 (commit author name: Rylan Cai)
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view "$PR" --json author` or `gh api search/users -f q='<email>'` before listing.
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
## Template
```md
# 🚀 LobeHub Release (<YYYYMMDD>)
**Release Date:** <Month DD, YYYY>
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
> <One release thesis sentence: what this release unlocks in practice.>
---
## ✨ Highlights
- **<Capability A>** — <What changed and why it matters>. (#1234)
- **<Capability B>** — <What changed and why it matters>. (#2345)
- **<Capability C>** — <What changed and why it matters>. (#3456)
---
## 🏗️ Core Product & Architecture
### <Subdomain>
- <Concrete change + impact>. (#...)
- <Concrete change + impact>. (#...)
---
## 📱 Platforms / Integrations
- <Platform update + impact>. (#...)
- <Compatibility/reliability fix + impact>. (#...)
---
## 🖥️ CLI & User Experience
- <User-facing workflow improvement>. (#...)
- <Quality-of-life fix>. (#...)
---
## 🔧 Tooling
- <Tool/runtime improvement>. (#...)
---
## 🔒 Security & Reliability
- **Security:** <hardening or vulnerability fix>. (#...)
- **Reliability:** <stability/performance behavior improvement>. (#...)
---
## 👥 Contributors
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
Plus @lobehubbot and renovate[bot] for maintenance.
---
**Full Changelog**: <previous_tag>...<current_tag>
```
## Quick Checklist
### Long-Form (Minor / Weekly)
- [ ] `PREV_TAG` is `git describe --tags --abbrev=0 origin/main` (latest semver), not the last weekly's tag
- [ ] Every `(#XXXX)` in the body appears in `/tmp/release_prs.txt` (verified via `comm -23`)
- [ ] `Since v…` line uses `$PREV_TAG`; PR / contributor counts match `wc -l` on the computed sets
- [ ] `**Full Changelog**` uses `$PREV_TAG...release/weekly-<YYYYMMDD>` (or `…v{x.y.z}` for minor)
- [ ] Author handles resolved via `gh pr view --json author`, not assumed from `%an`
- [ ] Uses top metadata and a clear release thesis
- [ ] Includes `Highlights` plus domain-grouped sections
- [ ] Every major bullet states both change and user/operator impact
- [ ] Security and reliability updates are explicitly surfaced (when present)
- [ ] Contributor credits and compare range are included
- [ ] All numbers and claims are verifiable
### Hotfix
- [ ] `**Hotfix Scope:**` line replaces metrics line
- [ ] Single quoted thesis describes what is restored (operator-facing, not internal)
- [ ] `## 🐛 What's Fixed` has 1-3 bullets, each `**<symptom>** — <fix>. (#PR)` with PR ref verified to exist and be merged
- [ ] `## ⚙️ Upgrade` notes self-hosted action and cloud auto-apply
- [ ] `## 👥 Owner` is a single `@handle` resolved via `gh pr view "$PR" --json author`
- [ ] No Highlights / domain blocks / Contributors / Full Changelog included
+57 -1
View File
@@ -1,6 +1,7 @@
---
name: zustand
description: Zustand state management guide. Use when working with store code (src/store/**), implementing actions, managing state, or creating slices. Triggers on Zustand store development, state management questions, or action implementation.
description: "LobeHub Zustand store conventions: public/internal/dispatch action layers, optimistic update pattern, slice composition via `flattenActions`, and class-based action migration. Use whenever working under `src/store/**`, adding a `createXxxSlice`, writing `internal_*` or `internal_dispatch*` actions, designing `messagesMap`/`topicsMap` reducers, refactoring a `StateCreator` object slice into a `XxxActionImpl` class, or debugging stale store reads. Triggers on `useChatStore`/`useUserStore`/`useGlobalStore`, `createStore`, `flattenActions`, `StoreSetter`, `internal_dispatch`, 'add an action', 'zustand selector', 'store slice', 'class action', 'optimistic update'."
user-invocable: false
---
# LobeHub Zustand State Management
@@ -174,9 +175,64 @@ export const chatGroupAction: StateCreator<
- `ChatGroupStoreWithRefresh` for member refresh
- `ChatGroupStoreWithInternal` for curd `internal_dispatchChatGroup`
### Slices That Don't Currently Need `set`
When a slice doesn't write local state at the moment — e.g. it reads context
from `#get()` and forwards calls to another store, or just runs hooks — drop
the `#set` field. Otherwise ESLint's `no-unused-vars` flags the unused private
field.
Mark the constructor's `set` param as `_set` and `void _set` it to keep the
`(set, get, api)` shape aligned with `StateCreator`. This is **a snapshot of
the current need, not a permanent contract** — if a later change needs `set`,
restore the `#set` field and use it; do not invent a workaround to keep the
"unused" form.
```ts
type Setter = StoreSetter<ConversationStore>;
export const toolSlice = (set: Setter, get: () => ConversationStore, _api?: unknown) =>
new ToolActionImpl(set, get, _api);
export class ToolActionImpl {
readonly #get: () => ConversationStore;
// Mark unused params with `_` prefix and `void _x` so the constructor still
// matches StateCreator's `(set, get, api)` shape without triggering unused
// diagnostics.
constructor(_set: Setter, get: () => ConversationStore, _api?: unknown) {
void _set;
void _api;
this.#get = get;
}
approveToolCall = async (id: string) => {
const { context, hooks } = this.#get();
await useChatStore.getState().approveToolCalling(id, '', context);
hooks.onToolCallComplete?.(id, undefined);
};
}
export type ToolAction = Pick<ToolActionImpl, keyof ToolActionImpl>;
```
Rules of thumb:
- If a slice doesn't currently call `set`, drop `#set` (use `_set` + `void _set`
in the constructor). When a later edit needs `set`, restore `#set` and use it.
- Don't add `setNamespace` for slices that don't write state. Add it when the
slice starts writing state.
- Never leave `#set` declared but unused "for future use" — lint will fail and
re-adding it later costs nothing.
### Do / Don't
- **Do**: keep constructor signature aligned with `StateCreator` params `(set, get, api)`.
- **Do**: use `#private` to avoid `set/get` being exposed.
- **Do**: use `flattenActions` instead of spreading class instances.
- **Do**: drop `#set` (and use `_set` + `void _set` in the constructor) for
delegate-only slices that never write state — keeps lint green without
breaking the `(set, get, api)` shape.
- **Don't**: keep both old slice objects and class actions active at the same time.
- **Don't**: keep an unused `#set` field "for future use" — it fails ESLint and
re-adding it later costs nothing.
+7 -10
View File
@@ -2,14 +2,13 @@
## Quick Reference by Name
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
- **@arvinxx**: General/uncategorized issues (default assignee), priority:high issues, tool calling, mcp, database
- **@canisminor1990**: Design, UI components, editor, markdown rendering
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
- **@tjx666**: Model providers and configuration, new model additions, image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
- **@ONLY-yours**: Performance, streaming, settings, web platform, marketplace, agent builder, schedule task
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
- **@sudongyuer**: Mobile app (React Native)
- **@sxjeru**: Model providers and configuration
- **@rdmclin2**: Team workspace, IM and bot integration
- **@tcmonster**: Subscription, refund, recharge, business cooperation
@@ -21,7 +20,7 @@ Quick reference for assigning issues based on labels.
| Label | Owner | Notes |
| ---------------- | ------- | -------------------------------------------- |
| All `provider:*` | @sxjeru | Model configuration and provider integration |
| All `provider:*` | @tjx666 | Model configuration and provider integration |
### Platform Labels (platform:\*)
@@ -100,11 +99,10 @@ Quick reference for assigning issues based on labels.
1. **Specific feature owner** - e.g., `feature:knowledge-base`@RiverTwilight
2. **Platform owner** - e.g., `platform:mobile`@sudongyuer
3. **Provider owner** - e.g., `provider:*`@sxjeru
3. **Provider owner** - e.g., `provider:*`@tjx666
4. **Component owner** - e.g., 💄 Design → @canisminor1990
5. **Infrastructure owner** - e.g., `deployment:*`@nekomeowww
6. **General maintainer** - @ONLY-yours for general bugs/issues
7. **Last resort** - @arvinxx (only if no clear owner)
6. **Default assignee** - @arvinxx for general/uncategorized issues
### Special Cases
@@ -121,8 +119,7 @@ Quick reference for assigning issues based on labels.
**No clear owner:**
- Assign to @ONLY-yours for general issues
- Only mention @arvinxx if critical and truly unclear
- Assign to @arvinxx for general issues
## Comment Templates
+20 -11
View File
@@ -56,7 +56,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# add your custom model name, multi model separate by comma. for example gpt-3.5-1106,gpt-4-1106
# OPENAI_MODEL_LIST=gpt-3.5-turbo
# ## Azure OpenAI ###
# you can learn azure OpenAI Service on https://learn.microsoft.com/en-us/azure/ai-services/openai/overview
@@ -71,7 +70,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# Azure's API version, follows the YYYY-MM-DD format
# AZURE_API_VERSION=2024-10-21
# ## Anthropic Service ####
# ANTHROPIC_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -79,19 +77,16 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# use a proxy to connect to the Anthropic API
# ANTHROPIC_PROXY_URL=https://api.anthropic.com
# ## Google AI ####
# GOOGLE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## AWS Bedrock ###
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxx
# AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## Ollama AI ####
# You can use ollama to get and run LLM locally, learn more about it via https://github.com/ollama/ollama
@@ -101,13 +96,11 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# OLLAMA_MODEL_LIST=your_ollama_model_names
# ## OpenRouter Service ###
# OPENROUTER_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OPENROUTER_MODEL_LIST=model1,model2,model3
# ## Mistral AI ###
# MISTRAL_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -168,7 +161,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# SILICONCLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## TencentCloud AI ####
# TENCENT_CLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -181,7 +173,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# INFINIAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## 302.AI ###
# AI302_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -222,7 +213,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# VERCELAIGATEWAY_API_KEY=your_vercel_ai_gateway_api_key
# #######################################
# ########### Market Service ############
# #######################################
@@ -283,7 +273,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# but some service providers may require configuration
# S3_REGION=us-west-1
# #######################################
# ########### Auth Service ##############
# #######################################
@@ -424,3 +413,23 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# MESSAGE_GATEWAY_ENABLED=1
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
# #######################################
# ########### Messenger Bot #############
# #######################################
# LobeHub-operated bots that users link their account to once and then chat
# with any of their agents from. Credentials (Telegram / Slack / Discord) are
# now managed in dc-center → Agent → System Bots and stored in the
# `system_bot_providers` table. See docs/development/messenger/managed-by-dc-center.md.
#
# Webhook URLs are registered against APP_URL:
# Telegram: <APP_URL>/api/agent/messenger/webhooks/telegram
# Slack: <APP_URL>/api/agent/messenger/webhooks/slack
# Discord: <APP_URL>/api/agent/messenger/webhooks/discord
#
# For local dev with bot platforms, point APP_URL at your tunnel
# (ngrok / cloudflared) so platforms can reach your machine.
# Verify-im link token TTL in seconds (default 1800 = 30 min)
# LOBE_LINK_TOKEN_TTL_SECONDS=1800
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
name: Test Packages
env:
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory model-bank'
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
steps:
- uses: actions/checkout@v6
+3
View File
@@ -148,3 +148,6 @@ apps/desktop/resources/cli-package.json
.superpowers/
docs/superpowers/
.heerogeneous-tracing
# Kagura agent runtime
.kagura/
+1 -1
View File
@@ -27,7 +27,7 @@ module.exports = defineConfig({
],
temperature: 0,
saveImmediately: true,
modelName: 'gpt-5.1-chat-latest',
modelName: 'gpt-4o',
experimental: {
jsonMode: true,
},
+5 -1
View File
@@ -121,4 +121,8 @@ cd packages/database && bunx vitest run --silent='passed-only' '[file]'
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
- Don't run `pnpm i18n` - CI handles it
- `pnpm i18n` is slow; run it manually when locale keys need updating (e.g. before opening a PR).
### Code Review
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
+112
View File
@@ -2,6 +2,118 @@
# Changelog
## [Version 2.1.57](https://github.com/lobehub/lobe-chat/compare/v2.1.57-canary.33...v2.1.57)
<sup>Released on **2026-05-09**</sup>
#### 🐛 Bug Fixes
- **docker**: replace pnpm init with static package.json in /deps.
- **onboarding**: guard skip/mode-switch footer with feature flag, desktop & init checks.
- **misc**: hide runtime-only model aliases.
#### ✨ Features
- **misc**: set OSS default model to DeepSeek V4 Pro.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **docker**: replace pnpm init with static package.json in /deps, closes [#14576](https://github.com/lobehub/lobe-chat/issues/14576) ([8ed31df](https://github.com/lobehub/lobe-chat/commit/8ed31df))
- **onboarding**: guard skip/mode-switch footer with feature flag, desktop & init checks, closes [#14560](https://github.com/lobehub/lobe-chat/issues/14560) ([9756dab](https://github.com/lobehub/lobe-chat/commit/9756dab))
- **misc**: hide runtime-only model aliases, closes [#14552](https://github.com/lobehub/lobe-chat/issues/14552) ([2d33322](https://github.com/lobehub/lobe-chat/commit/2d33322))
#### What's improved
- **misc**: set OSS default model to DeepSeek V4 Pro, closes [#14555](https://github.com/lobehub/lobe-chat/issues/14555) ([8105fc0](https://github.com/lobehub/lobe-chat/commit/8105fc0))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.56](https://github.com/lobehub/lobe-chat/compare/v2.1.55...v2.1.56)
<sup>Released on **2026-05-01**</sup>
#### 👷 Build System
- **database**: add `metadata` and `trigger` to `briefs` table.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Build System
- **database**: add `metadata` and `trigger` to `briefs` table, closes [#14354](https://github.com/lobehub/lobe-chat/issues/14354) ([86a23b5](https://github.com/lobehub/lobe-chat/commit/86a23b5))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.55](https://github.com/lobehub/lobe-chat/compare/v2.1.54...v2.1.55)
<sup>Released on **2026-04-29**</sup>
#### 🐛 Bug Fixes
- **chat**: preserve topics across cold route sends.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **chat**: preserve topics across cold route sends, closes [#14284](https://github.com/lobehub/lobe-chat/issues/14284) ([b8fe675](https://github.com/lobehub/lobe-chat/commit/b8fe675))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.54](https://github.com/lobehub/lobe-chat/compare/v2.1.53...v2.1.54)
<sup>Released on **2026-04-27**</sup>
#### 🐛 Bug Fixes
- **misc**: clear stale topic when switching agents from a topic route.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: clear stale topic when switching agents from a topic route, closes [#14231](https://github.com/lobehub/lobe-chat/issues/14231) ([deeb97a](https://github.com/lobehub/lobe-chat/commit/deeb97a))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.52](https://github.com/lobehub/lobe-chat/compare/v2.1.51...v2.1.52)
<sup>Released on **2026-04-20**</sup>
+1 -1
View File
@@ -89,7 +89,7 @@ RUN set -e && \
pnpm i && \
mkdir -p /deps && \
cd /deps && \
pnpm init && \
echo '{"name":"deps","private":true}' > package.json && \
pnpm add pg drizzle-orm
COPY . .
+80
View File
@@ -0,0 +1,80 @@
import { execSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
/**
* Manual E2E coverage for `lh agent space fs` against a real backend.
*
* Run when:
* - A local or remote LobeHub backend is reachable by the CLI
* - `AGENT_FS_E2E_AGENT_ID` points at an agent with document access
*
* Expects:
* - The command creates and cleans up a temporary VFS directory
* - This suite is skipped unless `AGENT_FS_E2E_AGENT_ID` is set
*/
const AGENT_ID = process.env.AGENT_FS_E2E_AGENT_ID;
const CLI = process.env.LH_CLI_PATH || 'LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts';
const TIMEOUT = 30_000;
function run(args: string): string {
return execSync(`${CLI} ${args}`, {
encoding: 'utf8',
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
timeout: TIMEOUT,
}).trim();
}
describe.skipIf(!AGENT_ID)('lh agent space fs unified VFS - manual E2E', () => {
const testRoot = `agent:/vfs-cli-e2e-${Date.now()}`;
it('exercises root, mounted namespaces, writes, copy, move, trash, and cleanup', () => {
const root = run(`agent space fs ls --agent-id ${AGENT_ID} agent:/`);
expect(root).toContain('lobe/');
const mountedRoot = run(`agent space fs ls --agent-id ${AGENT_ID} agent:/lobe/skills`);
expect(mountedRoot).toContain('builtin/');
expect(mountedRoot).toContain('agent/');
try {
expect(run(`agent space fs mkdir --agent-id ${AGENT_ID} --parents ${testRoot}`)).toContain(
'created',
);
expect(
run(
`agent space fs write --agent-id ${AGENT_ID} --content "# VFS E2E" ${testRoot}/source.md`,
),
).toContain('created');
expect(run(`agent space fs cat --agent-id ${AGENT_ID} ${testRoot}/source.md`)).toContain(
'# VFS E2E',
);
expect(
run(`agent space fs cp --agent-id ${AGENT_ID} ${testRoot}/source.md ${testRoot}/copied.md`),
).toContain('copied');
expect(
run(`agent space fs mv --agent-id ${AGENT_ID} ${testRoot}/copied.md ${testRoot}/moved.md`),
).toContain('moved');
expect(run(`agent space fs rm --agent-id ${AGENT_ID} --yes ${testRoot}/moved.md`)).toContain(
'deleted',
);
expect(run(`agent space fs trash ls --agent-id ${AGENT_ID} ${testRoot}`)).toContain(
`${testRoot}/moved.md`,
);
expect(
run(`agent space fs trash restore --agent-id ${AGENT_ID} ${testRoot}/moved.md`),
).toContain('restored');
} finally {
try {
run(`agent space fs rm --agent-id ${AGENT_ID} --yes --recursive ${testRoot}`);
} catch {
// Cleanup is best-effort because earlier assertions may fail before creation.
}
try {
run(`agent space fs trash rm --agent-id ${AGENT_ID} --yes --recursive --force ${testRoot}`);
} catch {
// Cleanup is best-effort because the trash entry may not exist.
}
}
}, 60_000);
});
+4 -4
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.8" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.15" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -68,15 +68,15 @@ Manage agent groups
.B bot
Manage bot integrations
.TP
.B cron
Manage agent cron jobs
.TP
.B generate
Generate content (text, image, video, speech) Alias: gen.
.TP
.B file
Manage files
.TP
.B hetero
Run heterogeneous agent CLIs (Claude Code / Codex) and stream their output
.TP
.B skill
Manage agent skills
.TP
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.8",
"version": "0.0.15",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -30,11 +30,13 @@
"devDependencies": {
"@lobechat/agent-gateway-client": "workspace:*",
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
"commander": "^13.1.0",
"dayjs": "^1.11.19",
"debug": "^4.4.0",
"diff": "^8.0.3",
"fast-glob": "^3.3.3",
+4
View File
@@ -1,6 +1,10 @@
packages:
- '../../packages/agent-gateway-client'
- '../../packages/device-gateway-client'
- '../../packages/heterogeneous-agents'
- '../../packages/local-file-shell'
- '../../packages/types'
- '../../packages/model-bank'
- '../../packages/business/const'
- '../../packages/file-loaders'
- '.'
+5 -3
View File
@@ -7,12 +7,14 @@ const CLIENT_ID = 'lobehub-cli';
* Get a valid access token, refreshing if expired.
* Returns null if no credentials or refresh fails.
*/
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
export async function getValidToken(
bufferSeconds = 60,
): Promise<{ credentials: StoredCredentials } | null> {
const credentials = loadCredentials();
if (!credentials) return null;
// Check if token is still valid (with 60s buffer)
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
// Check if token is still valid (with configurable buffer)
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - bufferSeconds) {
return { credentials };
}
+621 -7
View File
@@ -23,6 +23,24 @@ const { mockTrpcClient } = vi.hoisted(() => ({
updateAgentConfig: { mutate: vi.fn() },
updateAgentPinned: { mutate: vi.fn() },
},
agentDocument: {
copyDocumentByPath: { mutate: vi.fn() },
deleteDocumentByPath: { mutate: vi.fn() },
deleteDocumentPermanentlyByPath: { mutate: vi.fn() },
statDocumentByPath: { query: vi.fn() },
listDocumentsByPath: { query: vi.fn() },
listTrashDocumentsByPath: { query: vi.fn() },
mkdirDocumentByPath: { mutate: vi.fn() },
readDocumentByPath: { query: vi.fn() },
renameDocumentByPath: { mutate: vi.fn() },
restoreDocumentFromTrashByPath: { mutate: vi.fn() },
writeDocumentByPath: { mutate: vi.fn() },
},
agentSkills: {
createSkill: { mutate: vi.fn() },
deleteSkill: { mutate: vi.fn() },
updateSkill: { mutate: vi.fn() },
},
aiAgent: {
execAgent: { mutate: vi.fn() },
getOperationStatus: { query: vi.fn() },
@@ -41,6 +59,11 @@ const { mockStreamAgentEvents } = vi.hoisted(() => ({
mockStreamAgentEvents: vi.fn(),
}));
const { mockReplayAgentEvents, mockStreamAgentEventsViaWebSocket } = vi.hoisted(() => ({
mockReplayAgentEvents: vi.fn(),
mockStreamAgentEventsViaWebSocket: vi.fn(),
}));
const { mockGetAgentStreamAuthInfo } = vi.hoisted(() => ({
mockGetAgentStreamAuthInfo: vi.fn(),
}));
@@ -49,9 +72,18 @@ const { mockResolveLocalDeviceId } = vi.hoisted(() => ({
mockResolveLocalDeviceId: vi.fn(),
}));
const { mockReadStdinText } = vi.hoisted(() => ({
mockReadStdinText: vi.fn(),
}));
vi.mock('node:stream/consumers', () => ({ text: mockReadStdinText }));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../api/http', () => ({ getAgentStreamAuthInfo: mockGetAgentStreamAuthInfo }));
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
vi.mock('../utils/agentStream', () => ({
replayAgentEvents: mockReplayAgentEvents,
streamAgentEvents: mockStreamAgentEvents,
streamAgentEventsViaWebSocket: mockStreamAgentEventsViaWebSocket,
}));
vi.mock('../utils/device', () => ({ resolveLocalDeviceId: mockResolveLocalDeviceId }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
@@ -71,12 +103,26 @@ describe('agent command', () => {
serverUrl: 'https://example.com',
});
mockStreamAgentEvents.mockResolvedValue(undefined);
mockReplayAgentEvents.mockReset();
mockStreamAgentEventsViaWebSocket.mockReset();
mockStreamAgentEventsViaWebSocket.mockResolvedValue(undefined);
mockResolveLocalDeviceId.mockReset();
mockReadStdinText.mockReset();
for (const method of Object.values(mockTrpcClient.agent)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
for (const method of Object.values(mockTrpcClient.agentDocument)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
for (const method of Object.values(mockTrpcClient.agentSkills)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
for (const method of Object.values(mockTrpcClient.aiAgent)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
@@ -282,7 +328,7 @@ describe('agent command', () => {
});
describe('run', () => {
it('should exec agent and connect to SSE stream', async () => {
it('should exec agent and connect to the gateway WebSocket stream by default', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-123',
success: true,
@@ -304,11 +350,45 @@ describe('agent command', () => {
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', prompt: 'Hello' }),
);
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
expect.objectContaining({
gatewayUrl: expect.any(String),
json: undefined,
operationId: 'op-123',
serverUrl: 'https://example.com',
token: undefined,
tokenType: undefined,
verbose: undefined,
}),
);
expect(mockStreamAgentEvents).not.toHaveBeenCalled();
});
it('should fall back to SSE when --sse is provided', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-sse',
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hello',
'--sse',
]);
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
'https://example.com/api/agent/stream?operationId=op-123',
'https://example.com/api/agent/stream?operationId=op-sse',
expect.objectContaining({ 'Oidc-Auth': 'test-token' }),
expect.objectContaining({ json: undefined, verbose: undefined }),
);
expect(mockStreamAgentEventsViaWebSocket).not.toHaveBeenCalled();
});
it('should support --slug option', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
@@ -595,10 +675,8 @@ describe('agent command', () => {
'--json',
]);
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({ json: true }),
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
expect.objectContaining({ json: true, operationId: 'op-j' }),
);
});
});
@@ -794,4 +872,540 @@ describe('agent command', () => {
);
});
});
describe('fs', () => {
it('should list VFS entries from the unified agent root alias', async () => {
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([
{
mode: 8,
mount: { driver: 'synthetic', source: 'virtual' },
name: 'writer',
path: './lobe',
type: 'directory',
},
]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'ls',
'--agent-id',
'a1',
'agent:/',
'--json',
]);
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
agentId: 'a1',
cursor: undefined,
limit: undefined,
path: './',
topicId: undefined,
});
expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify(
[
{
mode: 8,
mount: { driver: 'synthetic', source: 'virtual' },
name: 'writer',
path: './lobe',
type: 'directory',
},
],
null,
2,
),
);
});
it('should pass pagination options to VFS ls', async () => {
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'ls',
'--agent-id',
'a1',
'--cursor',
'100',
'--limit',
'25',
'agent:/notes',
]);
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
agentId: 'a1',
cursor: '100',
limit: 25,
path: './notes',
topicId: undefined,
});
});
it('should print unix-like long listings with ls -la', async () => {
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([
{
mode: 14,
name: '.config',
path: './notes/.config',
size: 0,
type: 'directory',
updatedAt: '2026-04-27T07:18:00',
},
{
mode: 6,
name: 'SOUL.md',
path: './notes/SOUL.md',
size: 399,
type: 'file',
updatedAt: '2026-04-27T07:19:00',
},
]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'ls',
'-la',
'--agent-id',
'a1',
'agent:/notes',
]);
expect(consoleSpy).toHaveBeenNthCalledWith(1, 'total 1');
expect(consoleSpy).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/^dr-x------ {2}1 agent {2}agent {4}0 --- -- --:-- \.$/),
);
expect(consoleSpy).toHaveBeenNthCalledWith(
3,
expect.stringMatching(/^dr-x------ {2}1 agent {2}agent {4}0 --- -- --:-- \.\.$/),
);
expect(consoleSpy).toHaveBeenNthCalledWith(
4,
expect.stringMatching(/^drwx------ {2}1 agent {2}agent {4}0 Apr 27 07:18 \.config\/$/),
);
expect(consoleSpy).toHaveBeenNthCalledWith(
5,
expect.stringMatching(/^-rw------- {2}1 agent {2}agent {2}399 Apr 27 07:19 SOUL\.md$/),
);
});
it('should expose VFS commands through agent space fs', async () => {
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'ls',
'--agent-id',
'a1',
'agent:/notes',
]);
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
agentId: 'a1',
cursor: undefined,
limit: undefined,
path: './notes',
topicId: undefined,
});
});
it('should collect tree traversal warnings instead of failing the whole tree', async () => {
mockTrpcClient.agentDocument.listDocumentsByPath.query
.mockResolvedValueOnce([
{
mode: 8,
name: 'builtin',
path: './lobe/skills/builtin',
type: 'directory',
},
])
.mockRejectedValueOnce(new Error('Failed to list builtin skills'));
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'tree',
'--agent-id',
'a1',
'agent:/lobe/skills',
]);
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenNthCalledWith(1, {
agentId: 'a1',
path: './lobe/skills',
topicId: undefined,
});
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenNthCalledWith(2, {
agentId: 'a1',
path: './lobe/skills/builtin',
topicId: undefined,
});
expect(log.warn).toHaveBeenCalledWith('./lobe/skills/builtin: Failed to list builtin skills');
});
it('should read SKILL.md when cat targets a skill directory alias', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
mockTrpcClient.agentDocument.statDocumentByPath.query.mockResolvedValue({
content: '# Writer',
mode: 2,
mount: { driver: 'skills', namespace: 'builtin', source: 'builtin' },
name: 'SKILL.md',
path: './lobe/skills/builtin/skills/writer/SKILL.md',
type: 'file',
});
mockTrpcClient.agentDocument.readDocumentByPath.query.mockResolvedValue({
content: '# Writer',
contentType: 'text/markdown',
path: './lobe/skills/builtin/skills/writer/SKILL.md',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'cat',
'--agent-id',
'a1',
'builtin:/writer',
]);
expect(mockTrpcClient.agentDocument.statDocumentByPath.query).toHaveBeenCalledWith({
agentId: 'a1',
path: './lobe/skills/builtin/skills/writer/SKILL.md',
topicId: undefined,
});
expect(mockTrpcClient.agentDocument.readDocumentByPath.query).toHaveBeenCalledWith({
agentId: 'a1',
path: './lobe/skills/builtin/skills/writer/SKILL.md',
topicId: undefined,
});
expect(stdoutSpy).toHaveBeenCalledWith('# Writer');
stdoutSpy.mockRestore();
});
it('should create a writable skill through touch when the path does not exist', async () => {
mockTrpcClient.agentDocument.statDocumentByPath.query.mockRejectedValue({
data: { code: 'NOT_FOUND' },
});
mockTrpcClient.agentDocument.writeDocumentByPath.mutate.mockResolvedValue({
path: './lobe/skills/agent/skills/writer/SKILL.md',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'touch',
'--agent-id',
'a1',
'skills:/writer',
'--content',
'# Writer',
]);
expect(mockTrpcClient.agentDocument.writeDocumentByPath.mutate).toHaveBeenCalledWith({
agentId: 'a1',
content: '# Writer',
createMode: 'if-missing',
path: './lobe/skills/agent/skills/writer',
topicId: undefined,
});
});
it('should read write content from stdin when no content option is provided', async () => {
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false });
mockReadStdinText.mockResolvedValue('# Piped Content');
mockTrpcClient.agentDocument.statDocumentByPath.query.mockRejectedValue({
data: { code: 'NOT_FOUND' },
});
mockTrpcClient.agentDocument.writeDocumentByPath.mutate.mockResolvedValue({
path: './notes/piped.md',
});
try {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'write',
'--agent-id',
'a1',
'agent:/notes/piped.md',
]);
expect(mockReadStdinText).toHaveBeenCalledWith(process.stdin);
expect(mockTrpcClient.agentDocument.writeDocumentByPath.mutate).toHaveBeenCalledWith({
agentId: 'a1',
content: '# Piped Content',
createMode: 'if-missing',
path: './notes/piped.md',
topicId: undefined,
});
} finally {
if (stdinDescriptor) {
Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor);
}
}
});
it('should create directories through the generic mkdir path API', async () => {
mockTrpcClient.agentDocument.mkdirDocumentByPath.mutate.mockResolvedValue({
path: './notes/archive',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'mkdir',
'--agent-id',
'a1',
'--parents',
'agent:/notes/archive',
]);
expect(mockTrpcClient.agentDocument.mkdirDocumentByPath.mutate).toHaveBeenCalledWith({
agentId: 'a1',
path: './notes/archive',
recursive: true,
topicId: undefined,
});
});
it('should stat unified root paths', async () => {
mockTrpcClient.agentDocument.statDocumentByPath.query.mockResolvedValue({
mode: 8,
name: 'lobe',
path: './lobe',
type: 'directory',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'stat',
'--agent-id',
'a1',
'agent:/lobe',
'--json',
]);
expect(mockTrpcClient.agentDocument.statDocumentByPath.query).toHaveBeenCalledWith({
agentId: 'a1',
path: './lobe',
topicId: undefined,
});
});
it('should copy paths through the generic copyDocumentByPath API', async () => {
mockTrpcClient.agentDocument.copyDocumentByPath.mutate.mockResolvedValue({
path: './notes/published.md',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'cp',
'--agent-id',
'a1',
'--force',
'agent:/notes/draft.md',
'agent:/notes/published.md',
]);
expect(mockTrpcClient.agentDocument.copyDocumentByPath.mutate).toHaveBeenCalledWith({
agentId: 'a1',
force: true,
fromPath: './notes/draft.md',
toPath: './notes/published.md',
topicId: undefined,
});
});
it('should rename paths through the generic renameDocumentByPath API', async () => {
mockTrpcClient.agentDocument.renameDocumentByPath.mutate.mockResolvedValue({
path: './notes/final.md',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'mv',
'--agent-id',
'a1',
'agent:/notes/draft.md',
'agent:/notes/final.md',
]);
expect(mockTrpcClient.agentDocument.renameDocumentByPath.mutate).toHaveBeenCalledWith({
agentId: 'a1',
force: undefined,
fromPath: './notes/draft.md',
toPath: './notes/final.md',
topicId: undefined,
});
});
it('should soft-delete paths through the generic deleteDocumentByPath API', async () => {
mockTrpcClient.agentDocument.deleteDocumentByPath.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'rm',
'--agent-id',
'a1',
'--yes',
'--recursive',
'agent:/notes',
]);
expect(mockTrpcClient.agentDocument.deleteDocumentByPath.mutate).toHaveBeenCalledWith({
agentId: 'a1',
force: undefined,
path: './notes',
recursive: true,
topicId: undefined,
});
});
it('should list trash through the generic trash path API', async () => {
mockTrpcClient.agentDocument.listTrashDocumentsByPath.query.mockResolvedValue([
{ path: './notes/deleted.md' },
]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'trash',
'ls',
'--agent-id',
'a1',
'agent:/notes',
]);
expect(mockTrpcClient.agentDocument.listTrashDocumentsByPath.query).toHaveBeenCalledWith({
agentId: 'a1',
path: './notes',
topicId: undefined,
});
expect(consoleSpy).toHaveBeenCalledWith('agent:/notes/deleted.md');
});
it('should restore trash entries through the generic trash restore API', async () => {
mockTrpcClient.agentDocument.restoreDocumentFromTrashByPath.mutate.mockResolvedValue({
path: './notes/deleted.md',
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'trash',
'restore',
'--agent-id',
'a1',
'agent:/notes/deleted.md',
]);
expect(
mockTrpcClient.agentDocument.restoreDocumentFromTrashByPath.mutate,
).toHaveBeenCalledWith({
agentId: 'a1',
path: './notes/deleted.md',
topicId: undefined,
});
});
it('should permanently delete trash entries through the generic trash rm API', async () => {
mockTrpcClient.agentDocument.deleteDocumentPermanentlyByPath.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'space',
'fs',
'trash',
'rm',
'--agent-id',
'a1',
'--yes',
'--force',
'agent:/notes/deleted.md',
]);
expect(
mockTrpcClient.agentDocument.deleteDocumentPermanentlyByPath.mutate,
).toHaveBeenCalledWith({
agentId: 'a1',
force: true,
path: './notes/deleted.md',
recursive: undefined,
topicId: undefined,
});
});
});
});
+4 -25
View File
@@ -14,33 +14,12 @@ import {
import { resolveLocalDeviceId } from '../utils/device';
import { confirm, outputJson, printTable, truncate } from '../utils/format';
import { log, setVerbose } from '../utils/logger';
/**
* Resolve an agent identifier (agentId or slug) to a concrete agentId.
* When a slug is provided, uses getBuiltinAgent to look up the agent.
*/
async function resolveAgentId(
client: any,
opts: { agentId?: string; slug?: string },
): Promise<string> {
if (opts.agentId) return opts.agentId;
if (opts.slug) {
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
if (!agent) {
log.error(`Agent not found for slug: ${opts.slug}`);
process.exit(1);
}
return (agent as any).id || (agent as any).agentId;
}
log.error('Either <agentId> or --slug is required.');
process.exit(1);
return ''; // unreachable
}
import { resolveAgentId } from './agent/resolveAgentId';
import { registerAgentSpaceFsCommand } from './agent/spaceFs';
export function registerAgentCommand(program: Command) {
const agent = program.command('agent').description('Manage agents');
registerAgentSpaceFsCommand(agent);
// ── list ──────────────────────────────────────────────
@@ -339,7 +318,7 @@ export function registerAgentCommand(program: Command) {
}
// 1. Exec agent to get operationId
const input: Record<string, any> = { prompt: options.prompt };
const input: Record<string, any> = { prompt: options.prompt, trigger: 'cli' };
if (options.agentId) input.agentId = options.agentId;
if (deviceId) input.deviceId = deviceId;
if (options.slug) input.slug = options.slug;
@@ -0,0 +1,44 @@
import { log } from '../../utils/logger';
interface AgentLookupClient {
agent: {
getBuiltinAgent: {
query: (input: { slug: string }) => Promise<{ agentId?: string; id?: string } | null>;
};
};
}
/**
* Resolve an agent identifier into a concrete agent id.
*
* Use when:
* - A command accepts either a positional agent id or `--slug`.
* - Downstream tRPC calls require the concrete agent id.
*
* Expects:
* - `opts.agentId` to win over `opts.slug`.
* - `client.agent.getBuiltinAgent` to resolve slugs when needed.
*
* Returns:
* - The resolved agent id, or exits the process after logging a CLI-facing error.
*/
export async function resolveAgentId(
client: AgentLookupClient,
opts: { agentId?: string; slug?: string },
): Promise<string> {
if (opts.agentId) return opts.agentId;
if (opts.slug) {
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
if (!agent) {
log.error(`Agent not found for slug: ${opts.slug}`);
process.exit(1);
}
return agent.id || agent.agentId || '';
}
log.error('Either <agentId> or --slug is required.');
process.exit(1);
return '';
}
+908
View File
@@ -0,0 +1,908 @@
import { readFileSync } from 'node:fs';
import { text } from 'node:stream/consumers';
import type { Command } from 'commander';
import dayjs from 'dayjs';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { confirm, outputJson } from '../../utils/format';
import { log } from '../../utils/logger';
import { resolveAgentId } from './resolveAgentId';
const SKILL_FILE_NAME = 'SKILL.md';
const SKILL_NAMESPACE_PREFIXES = {
'agent': './lobe/skills/agent/skills',
'builtin': './lobe/skills/builtin/skills',
'installed-active': './lobe/skills/installed/active/skills',
'installed-all': './lobe/skills/installed/all/skills',
} as const;
const FS_PATH_ALIASES = {
'agent': './',
'builtin': 'builtin',
'skills': 'agent',
'installed-active': 'installed-active',
'installed-all': 'installed-all',
} as const;
type SkillFsNamespace = keyof typeof SKILL_NAMESPACE_PREFIXES;
type AgentFsClient = Awaited<ReturnType<typeof getTrpcClient>>;
interface AgentFsContext {
agentId: string;
topicId?: string;
}
interface AgentFsNode {
content?: string;
createdAt?: Date | string;
mode?: number;
mount?: {
driver?: string;
namespace?: string;
};
name: string;
path: string;
size?: number;
type: 'directory' | 'file';
updatedAt?: Date | string;
}
interface AgentFsResolvedPath {
filePath?: string;
namespace?: SkillFsNamespace;
path: string;
skillName?: string;
}
interface AgentFsOptions {
agentId?: string;
slug?: string;
topicId?: string;
}
function getTrpcErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') return undefined;
const value = error as {
data?: { code?: string };
shape?: { data?: { code?: string } };
};
return value.data?.code ?? value.shape?.data?.code;
}
function exitWithError(message: string): never {
log.error(message);
process.exit(1);
throw new Error(message);
}
function resolveAgentFsPath(input = 'agent:/'): AgentFsResolvedPath {
const raw = input.trim();
const aliasMatch = raw.match(/^([a-z-]+):(\/.*)?$/);
if (aliasMatch) {
const alias = aliasMatch[1] as keyof typeof FS_PATH_ALIASES;
const target = FS_PATH_ALIASES[alias];
if (!target) {
exitWithError(
`Unknown fs namespace "${aliasMatch[1]}". Use agent, skills, builtin, installed-all, or installed-active.`,
);
}
const suffix = aliasMatch[2]?.replace(/^\/+/, '').replace(/\/+$/, '') ?? '';
const prefix = target === './' ? './' : SKILL_NAMESPACE_PREFIXES[target as SkillFsNamespace];
return resolveAgentFsPath(suffix ? `${prefix}/${suffix}` : prefix);
}
if (raw === './' || raw === '.' || raw === '/') {
return { path: './' };
}
const match = Object.entries(SKILL_NAMESPACE_PREFIXES).find(([, prefix]) => {
return raw === prefix || raw.startsWith(`${prefix}/`);
});
if (!match) {
if (!raw.startsWith('./')) {
exitWithError(`Invalid fs path "${input}". Use aliases like "agent:/" or a full VFS path.`);
}
const normalizedPath = raw.replaceAll(/\/+/g, '/').replace(/\/+$/, '') || './';
return { path: normalizedPath };
}
const [namespace, prefix] = match as [
SkillFsNamespace,
(typeof SKILL_NAMESPACE_PREFIXES)[SkillFsNamespace],
];
const relativePath = raw.slice(prefix.length).replace(/^\/+/, '').replace(/\/+$/, '');
if (
relativePath.includes('//') ||
relativePath.split('/').some((segment) => segment === '.' || segment === '..')
) {
exitWithError(`Invalid fs path "${input}"`);
}
if (!relativePath) {
return { namespace, path: prefix };
}
const separatorIndex = relativePath.indexOf('/');
if (separatorIndex < 0) {
return {
namespace,
path: `${prefix}/${relativePath}`,
skillName: relativePath,
};
}
return {
filePath: relativePath.slice(separatorIndex + 1),
namespace,
path: `${prefix}/${relativePath}`,
skillName: relativePath.slice(0, separatorIndex),
};
}
function requireSkillNamespace(resolved: AgentFsResolvedPath): SkillFsNamespace {
if (!resolved.namespace) {
exitWithError(`Expected a skill namespace path, but received "${resolved.path}".`);
}
return resolved.namespace;
}
function canonicalSkillFilePath(resolved: AgentFsResolvedPath) {
if (!resolved.skillName) {
exitWithError('Expected a skill path, but received a namespace root.');
}
if (resolved.filePath && resolved.filePath !== SKILL_FILE_NAME) {
exitWithError(`Unsupported writable path "${resolved.path}". Only SKILL.md is mutable.`);
}
return `${SKILL_NAMESPACE_PREFIXES[requireSkillNamespace(resolved)]}/${resolved.skillName}/${SKILL_FILE_NAME}`;
}
function toDisplayPath(path: string) {
if (path === './') return 'agent:/';
if (path.startsWith('./') && path !== './lobe' && !path.startsWith('./lobe/')) {
return `agent:/${path.slice(2)}`;
}
for (const [namespace, prefix] of Object.entries(SKILL_NAMESPACE_PREFIXES) as Array<
[SkillFsNamespace, string]
>) {
const alias = namespace === 'agent' ? 'skills' : namespace;
if (path === prefix) return `${alias}:/`;
if (path.startsWith(`${prefix}/`)) return `${alias}:/${path.slice(prefix.length + 1)}`;
}
return path;
}
function isWritableNode(node: { mode?: number }) {
return ((node.mode ?? 0) & 4) !== 0;
}
function parseOptionalPositiveInteger(value?: string) {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
exitWithError(`Expected a positive integer, received "${value}".`);
}
return parsed;
}
function formatFsNodeName(node: { mode?: number; name: string; type: 'directory' | 'file' }) {
const suffix = node.type === 'directory' ? '/' : '';
return isWritableNode(node) ? `${node.name}${suffix}` : pc.dim(`${node.name}${suffix}`);
}
function getFsNodeDisplayName(node: Pick<AgentFsNode, 'name' | 'type'>) {
if (node.name === '.' || node.name === '..') return node.name;
return `${node.name}${node.type === 'directory' ? '/' : ''}`;
}
function getParentFsPath(path: string) {
if (path === './') return './';
const segments = path.replace(/^\.\//, '').split('/').filter(Boolean);
if (segments.length <= 1) return './';
return `./${segments.slice(0, -1).join('/')}`;
}
function createSyntheticListingNode(name: '.' | '..', path: string): AgentFsNode {
return {
mode: 10,
name,
path,
size: 0,
type: 'directory',
};
}
function formatFsPermissions(node: Pick<AgentFsNode, 'mode' | 'type'>) {
const mode = node.mode ?? 0;
const canRead = (mode & 2) !== 0 || (mode & 8) !== 0;
const canWrite = (mode & 4) !== 0;
const canExecute = (mode & 1) !== 0 || (node.type === 'directory' && (mode & 8) !== 0);
const owner = `${canRead ? 'r' : '-'}${canWrite ? 'w' : '-'}${canExecute ? 'x' : '-'}`;
return `${node.type === 'directory' ? 'd' : '-'}${owner}------`;
}
function formatFsLongDate(value?: Date | string) {
if (!value) return '--- -- --:--';
const date = dayjs(value);
if (!date.isValid()) return '--- -- --:--';
return date.format('MMM DD HH:mm');
}
function formatFsLongListing(nodes: AgentFsNode[]) {
const sizeWidth = Math.max(1, ...nodes.map((node) => String(node.size ?? 0).length));
const totalBlocks = nodes.reduce((total, node) => total + Math.ceil((node.size ?? 0) / 512), 0);
const lines = [`total ${totalBlocks}`];
for (const node of nodes) {
const size = String(node.size ?? 0).padStart(sizeWidth, ' ');
const mtime = formatFsLongDate(node.updatedAt ?? node.createdAt);
lines.push(
`${formatFsPermissions(node)} 1 agent agent ${size} ${mtime} ${getFsNodeDisplayName(node)}`,
);
}
return lines;
}
async function readFsContentInput(options: { content?: string; contentFile?: string }) {
if (options.contentFile) {
return readFileSync(options.contentFile, 'utf8');
}
if (options.content !== undefined) return options.content;
// NOTICE:
// CLI write commands should compose with shell pipelines without blocking interactive runs.
// Node marks piped stdin with `isTTY === false`, while normal terminals are `true` or undefined in tests.
// Remove this branch only if Commander gains first-class stdin option support for these commands.
if (process.stdin.isTTY === false) return text(process.stdin);
return '';
}
async function resolveAgentFsContext(client: AgentFsClient, options: AgentFsOptions) {
const agentId = await resolveAgentId(client, options);
return { agentId, topicId: options.topicId };
}
async function getFsNode(client: AgentFsClient, context: AgentFsContext, path: string) {
try {
return (await client.agentDocument.statDocumentByPath.query({
agentId: context.agentId,
path,
topicId: context.topicId,
})) as AgentFsNode;
} catch (error) {
if (getTrpcErrorCode(error) === 'NOT_FOUND') return undefined;
throw error;
}
}
async function readFsFile(client: AgentFsClient, context: AgentFsContext, inputPath: string) {
const resolved = resolveAgentFsPath(inputPath);
const readPath =
resolved.skillName && !resolved.filePath
? `${SKILL_NAMESPACE_PREFIXES[requireSkillNamespace(resolved)]}/${resolved.skillName}/${SKILL_FILE_NAME}`
: resolved.path;
const stat = await getFsNode(client, context, readPath);
if (!stat) {
exitWithError(`Path not found: ${inputPath}`);
}
if (stat.type !== 'file') {
exitWithError(`Cannot read directory path: ${inputPath}`);
}
const node = (await client.agentDocument.readDocumentByPath.query({
agentId: context.agentId,
path: readPath,
topicId: context.topicId,
})) as AgentFsNode;
return { node, resolved: resolveAgentFsPath(readPath) };
}
async function writeFsFile(
client: AgentFsClient,
context: AgentFsContext,
inputPath: string,
content: string,
) {
const resolved = resolveAgentFsPath(inputPath);
const existing = await getFsNode(
client,
context,
resolved.skillName && !resolved.filePath ? canonicalSkillFilePath(resolved) : resolved.path,
);
const result = await client.agentDocument.writeDocumentByPath.mutate({
agentId: context.agentId,
content,
createMode: existing ? 'must-exist' : 'if-missing',
path: resolved.path,
topicId: context.topicId,
});
return {
action: existing ? ('updated' as const) : ('created' as const),
path: result?.path ?? resolved.path,
};
}
async function mkdirFsPath(
client: AgentFsClient,
context: AgentFsContext,
inputPath: string,
options?: { recursive?: boolean },
) {
const resolved = resolveAgentFsPath(inputPath);
return client.agentDocument.mkdirDocumentByPath.mutate({
agentId: context.agentId,
path: resolved.path,
recursive: options?.recursive,
topicId: context.topicId,
});
}
async function deleteFsPath(
client: AgentFsClient,
context: AgentFsContext,
inputPath: string,
options?: { force?: boolean; recursive?: boolean },
) {
const resolved = resolveAgentFsPath(inputPath);
return client.agentDocument.deleteDocumentByPath.mutate({
agentId: context.agentId,
force: options?.force,
path: resolved.path,
recursive: options?.recursive,
topicId: context.topicId,
});
}
async function copyFsPath(
client: AgentFsClient,
context: AgentFsContext,
source: string,
destination: string,
force?: boolean,
) {
const sourceResolved = resolveAgentFsPath(source);
const destinationResolved = resolveAgentFsPath(destination);
return client.agentDocument.copyDocumentByPath.mutate({
agentId: context.agentId,
force,
fromPath: sourceResolved.path,
toPath: destinationResolved.path,
topicId: context.topicId,
});
}
async function renameFsPath(
client: AgentFsClient,
context: AgentFsContext,
source: string,
destination: string,
force?: boolean,
) {
const sourceResolved = resolveAgentFsPath(source);
const destinationResolved = resolveAgentFsPath(destination);
return client.agentDocument.renameDocumentByPath.mutate({
agentId: context.agentId,
force,
fromPath: sourceResolved.path,
toPath: destinationResolved.path,
topicId: context.topicId,
});
}
async function listTrashFsPath(client: AgentFsClient, context: AgentFsContext, inputPath?: string) {
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
return (await client.agentDocument.listTrashDocumentsByPath.query({
agentId: context.agentId,
path: resolved.path,
topicId: context.topicId,
})) as Array<Pick<AgentFsNode, 'path'>>;
}
async function restoreTrashFsPath(
client: AgentFsClient,
context: AgentFsContext,
inputPath: string,
) {
const resolved = resolveAgentFsPath(inputPath);
return client.agentDocument.restoreDocumentFromTrashByPath.mutate({
agentId: context.agentId,
path: resolved.path,
topicId: context.topicId,
});
}
async function deleteTrashFsPath(
client: AgentFsClient,
context: AgentFsContext,
inputPath: string,
options?: { force?: boolean; recursive?: boolean },
) {
const resolved = resolveAgentFsPath(inputPath);
return client.agentDocument.deleteDocumentPermanentlyByPath.mutate({
agentId: context.agentId,
force: options?.force,
path: resolved.path,
recursive: options?.recursive,
topicId: context.topicId,
});
}
async function printFsTree(
client: AgentFsClient,
context: AgentFsContext,
path: string,
prefix = '',
warnings: string[] = [],
) {
let nodes: AgentFsNode[];
try {
nodes = (await client.agentDocument.listDocumentsByPath.query({
agentId: context.agentId,
path,
topicId: context.topicId,
})) as AgentFsNode[];
} catch (error) {
const message = error instanceof Error ? error.message : 'failed to list path';
warnings.push(`${toDisplayPath(path)}: ${message}`);
return;
}
for (const [index, node] of nodes.entries()) {
const last = index === nodes.length - 1;
console.log(`${prefix}${last ? '└── ' : '├── '}${formatFsNodeName(node)}`);
if (node.type === 'directory') {
await printFsTree(client, context, node.path, `${prefix}${last ? ' ' : '│ '}`, warnings);
}
}
}
function registerFsCommands(fsCommand: Command) {
fsCommand
.command('ls [path]')
.description('List VFS entries')
.option('-a, --all', 'Include hidden entries')
.option('-l, --long', 'Use long listing format')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('--cursor <cursor>', 'Directory pagination cursor')
.option('-L, --limit <n>', 'Maximum number of entries')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
inputPath: string | undefined,
options: {
agentId?: string;
all?: boolean;
cursor?: string;
json?: string | boolean;
limit?: string;
long?: boolean;
slug?: string;
topicId?: string;
},
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
const nodes = ((await client.agentDocument.listDocumentsByPath.query({
agentId: context.agentId,
cursor: options.cursor,
limit: parseOptionalPositiveInteger(options.limit),
path: resolved.path,
topicId: context.topicId,
})) ?? []) as AgentFsNode[];
const filtered = options.all ? nodes : nodes.filter((node) => !node.name.startsWith('.'));
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(filtered, fields);
return;
}
if (options.long) {
const longNodes = options.all
? [
createSyntheticListingNode('.', resolved.path),
createSyntheticListingNode('..', getParentFsPath(resolved.path)),
...filtered,
]
: filtered;
formatFsLongListing(longNodes).forEach((line) => console.log(line));
return;
}
filtered.forEach((node) => console.log(formatFsNodeName(node)));
},
);
fsCommand
.command('tree [path]')
.description('Print a tree view of the VFS')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.action(
async (
inputPath: string | undefined,
options: { agentId?: string; slug?: string; topicId?: string },
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
console.log(pc.bold(toDisplayPath(resolved.path)));
const warnings: string[] = [];
await printFsTree(client, context, resolved.path, '', warnings);
for (const warning of warnings) {
log.warn(warning);
}
},
);
fsCommand
.command('cat <path>')
.description('Read a VFS file')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.action(
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const { node } = await readFsFile(client, context, inputPath);
process.stdout.write(node.content ?? '');
},
);
fsCommand
.command('stat <path>')
.description('Show VFS node metadata')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
inputPath: string,
options: {
agentId?: string;
json?: string | boolean;
slug?: string;
topicId?: string;
},
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const resolved = resolveAgentFsPath(inputPath);
const node = await getFsNode(client, context, resolved.path);
if (!node) {
exitWithError(`Path not found: ${inputPath}`);
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(node, fields);
return;
}
console.log(JSON.stringify(node, null, 2));
},
);
fsCommand
.command('touch <path>')
.description('Create or update a VFS file')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-c, --content <content>', 'File content')
.option('-F, --content-file <path>', 'Read content from a local file')
.action(
async (
inputPath: string,
options: {
agentId?: string;
content?: string;
contentFile?: string;
slug?: string;
topicId?: string;
},
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const content = await readFsContentInput(options);
const result = await writeFsFile(client, context, inputPath, content);
console.log(`${pc.green('✓')} ${result.action} ${pc.bold(toDisplayPath(result.path))}`);
},
);
fsCommand
.command('write <path>')
.description('Write content to a VFS file')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-c, --content <content>', 'File content')
.option('-F, --content-file <path>', 'Read content from a local file')
.action(
async (
inputPath: string,
options: {
agentId?: string;
content?: string;
contentFile?: string;
slug?: string;
topicId?: string;
},
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const content = await readFsContentInput(options);
const result = await writeFsFile(client, context, inputPath, content);
console.log(`${pc.green('✓')} ${result.action} ${pc.bold(toDisplayPath(result.path))}`);
},
);
fsCommand
.command('mkdir <path>')
.description('Create a VFS directory')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-p, --parents', 'Create parent directories as needed')
.action(
async (
inputPath: string,
options: { agentId?: string; parents?: boolean; slug?: string; topicId?: string },
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const result = await mkdirFsPath(client, context, inputPath, {
recursive: options.parents,
});
console.log(
`${pc.green('✓')} created ${pc.bold(toDisplayPath(result?.path ?? inputPath))}`,
);
},
);
fsCommand
.command('rm <path>')
.description('Delete a VFS node into trash')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-r, --recursive', 'Recursively delete a directory subtree')
.option('-f, --force', 'Forward force semantics to the VFS delete primitive')
.option('--yes', 'Skip confirmation prompt')
.action(
async (
inputPath: string,
options: {
agentId?: string;
force?: boolean;
recursive?: boolean;
slug?: string;
topicId?: string;
yes?: boolean;
},
) => {
if (!options.yes) {
const confirmed = await confirm(`Delete ${inputPath}?`);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
await deleteFsPath(client, context, inputPath, {
force: options.force,
recursive: options.recursive,
});
console.log(`${pc.green('✓')} deleted ${pc.bold(inputPath)}`);
},
);
fsCommand
.command('cp <source> <destination>')
.description('Copy a VFS node')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-f, --force', 'Overwrite the destination if it exists')
.action(
async (
source: string,
destination: string,
options: { agentId?: string; force?: boolean; slug?: string; topicId?: string },
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const result = await copyFsPath(client, context, source, destination, options.force);
console.log(
`${pc.green('✓')} copied ${pc.bold(source)}${pc.bold(toDisplayPath(result?.path ?? destination))}`,
);
},
);
fsCommand
.command('mv <source> <destination>')
.description('Move or rename a VFS node')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-f, --force', 'Overwrite the destination if it exists')
.action(
async (
source: string,
destination: string,
options: { agentId?: string; force?: boolean; slug?: string; topicId?: string },
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const sourceResolved = resolveAgentFsPath(source);
const destinationResolved = resolveAgentFsPath(destination);
if (sourceResolved.path === destinationResolved.path) {
console.log(`${pc.yellow('!')} source and destination are the same.`);
return;
}
const result = await renameFsPath(client, context, source, destination, options.force);
console.log(
`${pc.green('✓')} moved ${pc.bold(source)}${pc.bold(toDisplayPath(result?.path ?? destination))}`,
);
},
);
const trashCommand = fsCommand.command('trash').description('Operate on soft-deleted VFS nodes');
trashCommand
.command('ls [path]')
.description('List trashed VFS nodes')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
inputPath: string | undefined,
options: {
agentId?: string;
json?: string | boolean;
slug?: string;
topicId?: string;
},
) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const nodes = await listTrashFsPath(client, context, inputPath);
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(nodes, fields);
return;
}
if (nodes.length === 0) {
console.log('Trash is empty.');
return;
}
nodes.forEach((node) => console.log(toDisplayPath(node.path)));
},
);
trashCommand
.command('restore <path>')
.description('Restore a soft-deleted VFS node')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.action(
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const result = await restoreTrashFsPath(client, context, inputPath);
console.log(
`${pc.green('✓')} restored ${pc.bold(toDisplayPath(result?.path ?? inputPath))}`,
);
},
);
trashCommand
.command('rm <path>')
.description('Permanently delete a trashed VFS node')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-r, --recursive', 'Recursively delete a directory subtree')
.option('-f, --force', 'Forward force semantics to the permanent delete primitive')
.option('--yes', 'Skip confirmation prompt')
.action(
async (
inputPath: string,
options: {
agentId?: string;
force?: boolean;
recursive?: boolean;
slug?: string;
topicId?: string;
yes?: boolean;
},
) => {
if (!options.yes) {
const confirmed = await confirm(`Permanently delete ${inputPath}?`);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
await deleteTrashFsPath(client, context, inputPath, {
force: options.force,
recursive: options.recursive,
});
console.log(`${pc.green('✓')} permanently deleted ${pc.bold(inputPath)}`);
},
);
}
/**
* Register agent document VFS commands under `agent space fs`.
*
* Use when:
* - The CLI should expose filesystem-like operations for an agent document space.
* - Command registration should stay outside the core `agent` command file.
*
* Expects:
* - `agentCommand` to be the existing `agent` command group.
*
* Returns:
* - Registered Commander subcommands.
*/
export function registerAgentSpaceFsCommand(agentCommand: Command) {
const spaceCommand = agentCommand.command('space').description('Manage agent document space');
const fsCommand = spaceCommand.command('fs').description('Operate on the agent document VFS');
registerFsCommands(fsCommand);
}
+256 -1
View File
@@ -7,7 +7,54 @@ import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../util
import { log } from '../utils/logger';
import { registerBotMessageCommands } from './botMessage';
// ── Helpers ──────────────────────────────────────────────
// ── Access policy helpers ──────────────────────────────
const DM_POLICIES = ['open', 'allowlist', 'pairing', 'disabled'] as const;
const GROUP_POLICIES = ['open', 'allowlist', 'disabled'] as const;
type DmPolicy = (typeof DM_POLICIES)[number];
type GroupPolicy = (typeof GROUP_POLICIES)[number];
interface AllowEntry {
id: string;
name?: string;
}
/**
* Normalize an allow-list value into `{id, name?}[]`. Mirrors the server-side
* back-compat parser `settings.allowFrom` may be on disk as a comma-separated
* string, a bare `string[]`, or the current `{id, name?}[]` shape. The CLI
* needs the canonical form before push/filter operations and before sending
* back to the server.
*/
function normalizeAllowList(raw: unknown): AllowEntry[] {
if (typeof raw === 'string') {
return raw
.split(/[\s,]+/)
.map((id) => id.trim())
.filter(Boolean)
.map((id) => ({ id }));
}
if (!Array.isArray(raw)) return [];
const out: AllowEntry[] = [];
for (const entry of raw) {
if (typeof entry === 'string') {
const id = entry.trim();
if (id) out.push({ id });
continue;
}
if (entry && typeof entry === 'object' && 'id' in entry) {
const id = (entry as { id?: unknown }).id;
if (typeof id !== 'string' || !id.trim()) continue;
const name = (entry as { name?: unknown }).name;
out.push(
typeof name === 'string' && name.trim()
? { id: id.trim(), name: name.trim() }
: { id: id.trim() },
);
}
}
return out;
}
function maskValue(val: string): string {
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
@@ -78,6 +125,150 @@ async function resolvePlatform(client: TrpcClient, platformId: string) {
return def;
}
// ── Allowlist subcommand factory ────────────────────────
interface AllowlistGroupOptions {
/** Description shown by `lh bot <name> --help`. */
description: string;
/** Settings field to mutate — `allowFrom` (user IDs) or `groupAllowFrom` (channel IDs). */
fieldKey: 'allowFrom' | 'groupAllowFrom';
/** Human-friendly description of what the `<id>` arg represents. */
idLabel: string;
/** Subcommand group name (`allowlist` or `group-allowlist`). */
name: string;
}
/**
* Build a `list / add / remove / clear` subcommand group around an
* array-typed settings field (`allowFrom` or `groupAllowFrom`). All write
* paths read existing settings first and merge passing only a partial
* `settings` object to the TRPC `update` would replace the whole JSONB
* column and silently drop unrelated fields.
*/
function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
const group = bot.command(opts.name).description(opts.description);
// Read the current entries off a freshly-fetched bot row.
const readEntries = (bot: any): AllowEntry[] =>
normalizeAllowList((bot.settings as Record<string, unknown> | null)?.[opts.fieldKey]);
// Build the next settings payload from existing settings + the new entries.
const buildPayload = (bot: any, nextEntries: AllowEntry[]) => ({
id: bot.id,
settings: {
...(bot.settings as Record<string, unknown>),
[opts.fieldKey]: nextEntries,
},
});
group
.command('list <botId>')
.description(`List ${opts.fieldKey} entries`)
.option('--json', 'Output JSON')
.action(async (botId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (options.json) {
outputJson(entries);
return;
}
if (entries.length === 0) {
console.log(`${pc.dim(`No ${opts.fieldKey} entries.`)}`);
return;
}
printTable(
entries.map((e) => [e.id, e.name ?? pc.dim('-')]),
['ID', 'NAME'],
);
});
group
.command('add <botId> <id>')
.description(`Add a ${opts.idLabel} to ${opts.fieldKey}`)
.option('--name <name>', 'Optional human-friendly label so you can recognise the entry later')
.action(async (botId: string, id: string, options: { name?: string }) => {
const trimmedId = id.trim();
if (!trimmedId) {
log.error('ID cannot be empty.');
process.exit(1);
return;
}
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (entries.some((e) => e.id === trimmedId)) {
log.info(`${trimmedId} is already on the ${opts.fieldKey} list — nothing to do.`);
return;
}
const trimmedName = options.name?.trim();
const next = [
...entries,
trimmedName ? { id: trimmedId, name: trimmedName } : { id: trimmedId },
];
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
console.log(
`${pc.green('✓')} Added ${pc.bold(trimmedId)}${trimmedName ? ` (${trimmedName})` : ''} to ${opts.fieldKey} (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
);
});
group
.command('remove <botId> <id>')
.description(`Remove a ${opts.idLabel} from ${opts.fieldKey}`)
.action(async (botId: string, id: string) => {
const trimmedId = id.trim();
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
const next = entries.filter((e) => e.id !== trimmedId);
if (next.length === entries.length) {
log.info(`${trimmedId} is not on the ${opts.fieldKey} list — nothing to do.`);
return;
}
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
console.log(
`${pc.green('✓')} Removed ${pc.bold(trimmedId)} from ${opts.fieldKey} (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
);
});
group
.command('clear <botId>')
.description(`Clear all entries from ${opts.fieldKey}`)
.option('--yes', 'Skip confirmation prompt')
.action(async (botId: string, options: { yes?: boolean }) => {
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (entries.length === 0) {
log.info(`${opts.fieldKey} is already empty — nothing to do.`);
return;
}
if (!options.yes) {
const confirmed = await confirm(
`Clear all ${entries.length} ${opts.fieldKey} entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
console.log(`${pc.green('✓')} Cleared ${opts.fieldKey} on bot ${pc.bold(botId)}`);
});
}
// ── Command Registration ─────────────────────────────────
export function registerBotCommand(program: Command) {
@@ -313,6 +504,16 @@ export function registerBotCommand(program: Command) {
.option('--verification-token <token>', 'New verification token')
.option('--app-id <appId>', 'New application ID')
.option('--platform <platform>', 'New platform')
.option(
'--dm-policy <policy>',
`DM access policy (${DM_POLICIES.join('|')}). 'pairing' requires --user-id.`,
)
.option('--group-policy <policy>', `Group/channel access policy (${GROUP_POLICIES.join('|')})`)
.option(
'--user-id <id>',
"Owner's platform user ID (required for --dm-policy=pairing; auto-trusts the operator in the global allowlist)",
)
.option('--server-id <id>', 'Default server / guild / workspace ID for AI tool calls')
.action(
async (
botId: string,
@@ -321,11 +522,15 @@ export function registerBotCommand(program: Command) {
appSecret?: string;
botId?: string;
botToken?: string;
dmPolicy?: string;
encryptKey?: string;
groupPolicy?: string;
platform?: string;
publicKey?: string;
secretToken?: string;
serverId?: string;
signingSecret?: string;
userId?: string;
verificationToken?: string;
webhookProxyUrl?: string;
},
@@ -342,6 +547,40 @@ export function registerBotCommand(program: Command) {
if (options.appId) input.applicationId = options.appId;
if (options.platform) input.platform = options.platform;
// ── Settings (DM / group policy + identity fields) ────────────
// Read-modify-write so we don't wipe `allowFrom`, `groupAllowFrom`,
// or any other settings field the operator already configured.
const settingsPatch: Record<string, unknown> = {};
if (options.dmPolicy !== undefined) {
if (!(DM_POLICIES as readonly string[]).includes(options.dmPolicy)) {
log.error(
`Invalid --dm-policy "${options.dmPolicy}". Must be one of: ${DM_POLICIES.join(', ')}`,
);
process.exit(1);
return;
}
settingsPatch.dmPolicy = options.dmPolicy as DmPolicy;
}
if (options.groupPolicy !== undefined) {
if (!(GROUP_POLICIES as readonly string[]).includes(options.groupPolicy)) {
log.error(
`Invalid --group-policy "${options.groupPolicy}". Must be one of: ${GROUP_POLICIES.join(', ')}`,
);
process.exit(1);
return;
}
settingsPatch.groupPolicy = options.groupPolicy as GroupPolicy;
}
if (options.userId !== undefined) settingsPatch.userId = options.userId;
if (options.serverId !== undefined) settingsPatch.serverId = options.serverId;
if (Object.keys(settingsPatch).length > 0) {
input.settings = {
...(existing.settings as Record<string, unknown>),
...settingsPatch,
};
}
if (Object.keys(input).length <= 1) {
log.error('No changes specified.');
process.exit(1);
@@ -353,6 +592,22 @@ export function registerBotCommand(program: Command) {
},
);
// ── allowlist (DM / group user gate) ──────────────────
registerAllowlistCommand(bot, {
description: 'Manage the global user allowlist (gates DMs and group @mentions)',
fieldKey: 'allowFrom',
idLabel: 'platform user ID',
name: 'allowlist',
});
registerAllowlistCommand(bot, {
description: 'Manage the group/channel allowlist (used when groupPolicy=allowlist)',
fieldKey: 'groupAllowFrom',
idLabel: 'channel / group / thread ID',
name: 'group-allowlist',
});
// ── remove ────────────────────────────────────────────
bot
+4 -5
View File
@@ -55,7 +55,7 @@ export function registerBriefCommand(program: Command) {
typeBadge(b.type, b.priority),
truncate(b.title, 40),
truncate(b.summary, 50),
b.taskId ? pc.dim(b.taskId) : b.cronJobId ? pc.dim(b.cronJobId) : '-',
b.taskId ? pc.dim(b.taskId) : '-',
b.resolvedAt ? pc.green('resolved') : b.readAt ? pc.dim('read') : 'new',
timeAgo(b.createdAt),
]);
@@ -102,7 +102,6 @@ export function registerBriefCommand(program: Command) {
console.log(`${pc.dim('Type:')} ${b.type} ${pc.dim('Created:')} ${timeAgo(b.createdAt)}`);
if (b.agentId) console.log(`${pc.dim('Agent:')} ${b.agentId}`);
if (b.taskId) console.log(`${pc.dim('Task:')} ${b.taskId}`);
if (b.cronJobId) console.log(`${pc.dim('CronJob:')} ${b.cronJobId}`);
if (b.topicId) console.log(`${pc.dim('Topic:')} ${b.topicId}`);
console.log(`\n${b.summary}`);
@@ -121,14 +120,14 @@ export function registerBriefCommand(program: Command) {
for (const a of actions) {
const cmd =
a.type === 'comment'
? `lh brief resolve ${b.id} --action ${a.key} -m "内容"`
? `lh brief resolve ${b.id} --action ${a.key} -m "message"`
: `lh brief resolve ${b.id} --action ${a.key}`;
console.log(` ${a.label} ${pc.dim(cmd)}`);
}
} else {
console.log(pc.dim('Actions:'));
console.log(pc.dim(` lh brief resolve ${b.id} # 确认通过`));
console.log(pc.dim(` lh brief resolve ${b.id} --reply "修改意见" # 反馈修改`));
console.log(pc.dim(` lh brief resolve ${b.id} # Approve`));
console.log(pc.dim(` lh brief resolve ${b.id} --reply "revision notes" # Request revision`));
}
} else if ((b as any).resolvedComment) {
console.log(`${pc.dim('Comment:')} ${(b as any).resolvedComment}`);
+31 -1
View File
@@ -1,6 +1,11 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../auth/refresh', () => ({
getValidToken: vi.fn().mockResolvedValue({
credentials: { accessToken: 'test-token', expiresAt: undefined, refreshToken: 'test-refresh' },
}),
}));
vi.mock('../auth/resolveToken', () => ({
resolveToken: vi.fn().mockResolvedValue({
serverUrl: 'https://app.lobehub.com',
@@ -83,16 +88,21 @@ vi.mock('@lobechat/device-gateway-client', () => ({
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
clientEventHandlers[event] = handler;
}),
reconnect: vi.fn().mockResolvedValue(undefined),
sendSystemInfoResponse: vi.fn().mockImplementation((data: any) => {
lastSentSystemInfoResponse = data;
}),
sendToolCallResponse: vi.fn().mockImplementation((data: any) => {
lastSentToolResponse = data;
}),
updateToken: vi.fn(),
};
}),
}));
// eslint-disable-next-line import-x/first
import { GatewayClient } from '@lobechat/device-gateway-client';
// eslint-disable-next-line import-x/first
import { resolveToken } from '../auth/resolveToken';
// eslint-disable-next-line import-x/first
@@ -242,13 +252,33 @@ describe('connect command', () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['auth_failed']?.('invalid token');
await clientEventHandlers['auth_failed']?.('invalid token');
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should retry auth_failed with token refresh when new token available', async () => {
vi.mocked(resolveToken).mockResolvedValueOnce({
serverUrl: 'https://app.lobehub.com',
token: 'refreshed-token',
tokenType: 'jwt',
userId: 'test-user',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
const mockClient = vi.mocked(GatewayClient).mock.results[0].value;
await clientEventHandlers['auth_failed']?.('token expired');
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Token refreshed'));
expect(mockClient.updateToken).toHaveBeenCalledWith('refreshed-token');
expect(exitSpy).not.toHaveBeenCalled();
});
it('should handle auth_expired', async () => {
vi.mocked(resolveToken).mockResolvedValueOnce({
serverUrl: 'https://app.lobehub.com',
+104 -3
View File
@@ -10,6 +10,7 @@ import type {
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { getValidToken } from '../auth/refresh';
import { resolveToken } from '../auth/resolveToken';
import { CLI_API_KEY_ENV } from '../constants/auth';
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
@@ -284,8 +285,44 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
updateStatus('reconnecting');
});
// Handle auth failed
client.on('auth_failed', (reason) => {
// Proactive token refresh — schedule before JWT expires
const startProactiveRefresh = () =>
scheduleProactiveRefresh(
auth,
(refreshed) => {
client.updateToken(refreshed.token);
auth = refreshed;
// Schedule next refresh based on the new token
cancelRefreshTimer = startProactiveRefresh();
},
info,
error,
);
let cancelRefreshTimer = startProactiveRefresh();
// Handle auth failed — attempt token refresh once before giving up
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
let authFailedRefreshAttempted = false;
client.on('auth_failed', async (reason) => {
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
authFailedRefreshAttempted = true;
info(`Authentication failed (${reason}). Attempting token refresh...`);
try {
const refreshed = await resolveToken({});
if (refreshed && refreshed.token !== auth.token) {
info('Token refreshed successfully. Reconnecting...');
client.updateToken(refreshed.token);
auth = refreshed;
authFailedRefreshAttempted = false;
cancelRefreshTimer = startProactiveRefresh();
await client.reconnect();
return;
}
} catch {
// fall through
}
}
error(`Authentication failed: ${reason}`);
error(
`Run 'lh login', or set ${CLI_API_KEY_ENV} and run 'lh login --server <url>' to configure API key authentication.`,
@@ -308,8 +345,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
if (refreshed) {
info('Token refreshed successfully. Reconnecting...');
client.updateToken(refreshed.token);
// Update cached auth so subsequent refreshes use the latest token
auth = refreshed;
cancelRefreshTimer = startProactiveRefresh();
await client.reconnect();
return;
}
@@ -330,6 +367,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Graceful shutdown
const cleanup = () => {
info('Shutting down...');
cancelRefreshTimer?.();
cleanupAllProcesses();
client.disconnect();
removeStatus();
@@ -374,6 +412,69 @@ function formatUptime(startedAt: Date): string {
return `${seconds}s`;
}
// How far before expiry to proactively refresh (1 hour)
const PROACTIVE_REFRESH_BUFFER = 60 * 60;
/**
* Parse the `exp` claim from a JWT without verifying the signature.
*/
function parseJwtExp(token: string): number | undefined {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
return typeof payload.exp === 'number' ? payload.exp : undefined;
} catch {
return undefined;
}
}
/**
* Schedule a proactive token refresh before the JWT expires.
* Returns a cleanup function that cancels the scheduled timer.
*/
function scheduleProactiveRefresh(
auth: { token: string; tokenType: string },
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
info: (msg: string) => void,
error: (msg: string) => void,
): (() => void) | null {
if (auth.tokenType !== 'jwt') return null;
const exp = parseJwtExp(auth.token);
if (!exp) return null;
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
const delay = refreshAt - Date.now();
if (delay < 0) {
// Already past the refresh window — refresh immediately on next tick
void doRefresh();
return null;
}
const timer = setTimeout(() => void doRefresh(), delay);
return () => clearTimeout(timer);
async function doRefresh() {
try {
// Use the same buffer so getValidToken actually triggers a refresh
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
if (!result) {
error('Proactive token refresh failed — no valid credentials.');
return;
}
const refreshed = await resolveToken({});
// Only notify if the token actually changed to avoid reschedule loops
if (refreshed.token !== auth.token) {
info('Proactively refreshed token.');
onRefreshed(refreshed);
}
} catch {
error('Proactive token refresh failed.');
}
}
}
function collectSystemInfo(): DeviceSystemInfo {
const home = os.homedir();
const platform = process.platform;
-172
View File
@@ -1,172 +0,0 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerCronCommand } from './cron';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
agentCronJob: {
batchUpdateStatus: { mutate: vi.fn() },
create: { mutate: vi.fn() },
delete: { mutate: vi.fn() },
findById: { query: vi.fn() },
getStats: { query: vi.fn() },
list: { query: vi.fn() },
resetExecutions: { mutate: vi.fn() },
update: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
describe('cron command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const method of Object.values(mockTrpcClient.agentCronJob)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerCronCommand(program);
return program;
}
describe('list', () => {
it('should list cron jobs', async () => {
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({
data: [{ enabled: true, id: 'c1', name: 'Test Job', schedule: '* * * * *' }],
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'list']);
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalled();
});
it('should filter by agent-id', async () => {
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({ data: [] });
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'list', '--agent-id', 'a1']);
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1' }),
);
});
});
describe('view', () => {
it('should view cron job details', async () => {
mockTrpcClient.agentCronJob.findById.query.mockResolvedValue({
data: { enabled: true, id: 'c1', name: 'Test', schedule: '* * * * *' },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'view', 'c1']);
expect(mockTrpcClient.agentCronJob.findById.query).toHaveBeenCalledWith({ id: 'c1' });
});
});
describe('create', () => {
it('should create a cron job', async () => {
mockTrpcClient.agentCronJob.create.mutate.mockResolvedValue({ data: { id: 'c1' } });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'cron',
'create',
'--agent-id',
'a1',
'-s',
'* * * * *',
'-n',
'My Job',
]);
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
);
});
});
describe('delete', () => {
it('should delete a cron job', async () => {
mockTrpcClient.agentCronJob.delete.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'delete', 'c1', '--yes']);
expect(mockTrpcClient.agentCronJob.delete.mutate).toHaveBeenCalledWith({ id: 'c1' });
});
});
describe('toggle', () => {
it('should batch enable cron jobs', async () => {
mockTrpcClient.agentCronJob.batchUpdateStatus.mutate.mockResolvedValue({
data: { updatedCount: 2 },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'toggle', 'c1', 'c2', '--enable']);
expect(mockTrpcClient.agentCronJob.batchUpdateStatus.mutate).toHaveBeenCalledWith({
enabled: true,
ids: ['c1', 'c2'],
});
});
});
describe('reset', () => {
it('should reset execution count', async () => {
mockTrpcClient.agentCronJob.resetExecutions.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'reset', 'c1', '--max', '100']);
expect(mockTrpcClient.agentCronJob.resetExecutions.mutate).toHaveBeenCalledWith({
id: 'c1',
newMaxExecutions: 100,
});
});
});
describe('stats', () => {
it('should get stats', async () => {
mockTrpcClient.agentCronJob.getStats.query.mockResolvedValue({
data: { totalJobs: 5, totalExecutions: 100 },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'stats']);
expect(mockTrpcClient.agentCronJob.getStats.query).toHaveBeenCalled();
});
});
});
-271
View File
@@ -1,271 +0,0 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
export function registerCronCommand(program: Command) {
const cron = program.command('cron').description('Manage agent cron jobs');
// ── list ──────────────────────────────────────────────
cron
.command('list')
.description('List cron jobs')
.option('--agent-id <id>', 'Filter by agent ID')
.option('--enabled', 'Only show enabled jobs')
.option('--disabled', 'Only show disabled jobs')
.option('-L, --limit <n>', 'Page size', '20')
.option('--offset <n>', 'Offset', '0')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (options: {
agentId?: string;
disabled?: boolean;
enabled?: boolean;
json?: string | boolean;
limit?: string;
offset?: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {};
if (options.agentId) input.agentId = options.agentId;
if (options.enabled) input.enabled = true;
if (options.disabled) input.enabled = false;
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
const result = await client.agentCronJob.list.query(input as any);
const items = (result as any).data ?? [];
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(items, fields);
return;
}
if (items.length === 0) {
console.log('No cron jobs found.');
return;
}
const rows = items.map((j: any) => [
j.id || '',
truncate(j.name || '', 30),
j.schedule || '',
j.enabled ? pc.green('enabled') : pc.dim('disabled'),
`${j.executionCount ?? 0}/${j.maxExecutions ?? '∞'}`,
j.updatedAt ? timeAgo(j.updatedAt) : '',
]);
printTable(rows, ['ID', 'NAME', 'SCHEDULE', 'STATUS', 'EXECUTIONS', 'UPDATED']);
},
);
// ── view ──────────────────────────────────────────────
cron
.command('view <id>')
.description('View cron job details')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.agentCronJob.findById.query({ id });
const job = (result as any).data;
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(job, fields);
return;
}
if (!job) {
log.error('Cron job not found.');
process.exit(1);
}
console.log(`${pc.bold('ID:')} ${job.id}`);
console.log(`${pc.bold('Name:')} ${job.name || ''}`);
console.log(`${pc.bold('Agent ID:')} ${job.agentId || ''}`);
console.log(`${pc.bold('Schedule:')} ${job.schedule || ''}`);
console.log(
`${pc.bold('Status:')} ${job.enabled ? pc.green('enabled') : pc.dim('disabled')}`,
);
console.log(
`${pc.bold('Executions:')} ${job.executionCount ?? 0}/${job.maxExecutions ?? '∞'}`,
);
if (job.prompt) console.log(`${pc.bold('Prompt:')} ${truncate(job.prompt, 80)}`);
if (job.createdAt) console.log(`${pc.bold('Created:')} ${timeAgo(job.createdAt)}`);
if (job.updatedAt) console.log(`${pc.bold('Updated:')} ${timeAgo(job.updatedAt)}`);
});
// ── create ────────────────────────────────────────────
cron
.command('create')
.description('Create a cron job')
.requiredOption('--agent-id <id>', 'Agent ID')
.requiredOption('-s, --schedule <cron>', 'Cron schedule expression')
.option('-n, --name <name>', 'Job name')
.option('-p, --prompt <prompt>', 'Prompt text')
.option('--max-executions <n>', 'Maximum number of executions')
.option('--json', 'Output JSON')
.action(
async (options: {
agentId: string;
json?: boolean;
maxExecutions?: string;
name?: string;
prompt?: string;
schedule: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {
agentId: options.agentId,
cronPattern: options.schedule,
};
if (options.name) input.name = options.name;
if (options.prompt) input.content = options.prompt;
if (options.maxExecutions) input.maxExecutions = Number.parseInt(options.maxExecutions, 10);
const result = await client.agentCronJob.create.mutate(input as any);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const data = (result as any).data;
console.log(`${pc.green('✓')} Created cron job ${pc.bold(data?.id || '')}`);
},
);
// ── edit ───────────────────────────────────────────────
cron
.command('edit <id>')
.description('Update a cron job')
.option('-n, --name <name>', 'Job name')
.option('-s, --schedule <cron>', 'Cron schedule expression')
.option('-p, --prompt <prompt>', 'Prompt text')
.option('--max-executions <n>', 'Maximum number of executions')
.option('--enable', 'Enable the job')
.option('--disable', 'Disable the job')
.action(
async (
id: string,
options: {
disable?: boolean;
enable?: boolean;
maxExecutions?: string;
name?: string;
prompt?: string;
schedule?: string;
},
) => {
const data: Record<string, any> = {};
if (options.name) data.name = options.name;
if (options.schedule) data.cronPattern = options.schedule;
if (options.prompt) data.content = options.prompt;
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
if (options.enable) data.enabled = true;
if (options.disable) data.enabled = false;
if (Object.keys(data).length === 0) {
log.error(
'No changes specified. Use --name, --schedule, --prompt, --enable, or --disable.',
);
process.exit(1);
}
const client = await getTrpcClient();
await client.agentCronJob.update.mutate({ data, id } as any);
console.log(`${pc.green('✓')} Updated cron job ${pc.bold(id)}`);
},
);
// ── delete ────────────────────────────────────────────
cron
.command('delete <id>')
.description('Delete a cron job')
.option('--yes', 'Skip confirmation prompt')
.action(async (id: string, options: { yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm('Are you sure you want to delete this cron job?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.agentCronJob.delete.mutate({ id });
console.log(`${pc.green('✓')} Deleted cron job ${pc.bold(id)}`);
});
// ── toggle ────────────────────────────────────────────
cron
.command('toggle <ids...>')
.description('Batch enable or disable cron jobs')
.option('--enable', 'Enable the jobs')
.option('--disable', 'Disable the jobs')
.action(async (ids: string[], options: { disable?: boolean; enable?: boolean }) => {
if (!options.enable && !options.disable) {
log.error('Specify --enable or --disable.');
process.exit(1);
}
const enabled = !!options.enable;
const client = await getTrpcClient();
const result = await client.agentCronJob.batchUpdateStatus.mutate({ enabled, ids });
const count = (result as any).data?.updatedCount ?? ids.length;
console.log(`${pc.green('✓')} ${enabled ? 'Enabled' : 'Disabled'} ${count} cron job(s)`);
});
// ── reset ─────────────────────────────────────────────
cron
.command('reset <id>')
.description('Reset execution count for a cron job')
.option('--max <n>', 'Set new max executions')
.action(async (id: string, options: { max?: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { id };
if (options.max) input.newMaxExecutions = Number.parseInt(options.max, 10);
await client.agentCronJob.resetExecutions.mutate(input as any);
console.log(`${pc.green('✓')} Reset execution count for ${pc.bold(id)}`);
});
// ── stats ─────────────────────────────────────────────
cron
.command('stats')
.description('Get cron job execution statistics')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const result = await client.agentCronJob.getStats.query();
const stats = (result as any).data;
if (options.json) {
console.log(JSON.stringify(stats, null, 2));
return;
}
if (!stats) {
console.log('No statistics available.');
return;
}
for (const [key, value] of Object.entries(stats as Record<string, any>)) {
console.log(`${pc.bold(key + ':')} ${value}`);
}
});
}
+89 -13
View File
@@ -9,6 +9,61 @@ import { registerTextCommand } from './text';
import { registerTtsCommand } from './tts';
import { registerVideoCommand } from './video';
/**
* Parse a tRPC/server error and return a user-friendly message for gen status/download.
*
* getGenerationStatus throws NOT_FOUND in two distinct cases:
* 1. "Async task not found" asyncTaskId is wrong (user passed gen_xxx instead of UUID)
* 2. "Generation not found" generationId is wrong
*
* INTERNAL_SERVER_ERROR with a message mentioning "async_tasks" also indicates a bad asyncTaskId
* (e.g. the server SQL query fails when a non-UUID is passed).
*/
function parseGenStatusError(
err: any,
generationId: string,
asyncTaskId: string,
command: 'status' | 'download',
): string | null {
const code = err?.data?.code || err?.shape?.data?.code;
const message: string = err?.message || err?.shape?.message || '';
const isAsyncTaskNotFound =
(code === 'NOT_FOUND' && message.includes('Async task not found')) ||
(code === 'INTERNAL_SERVER_ERROR' && message.includes('async_tasks'));
const isGenerationNotFound = code === 'NOT_FOUND' && message.includes('Generation not found');
if (isAsyncTaskNotFound) {
return (
`${pc.red('✗')} Async task not found: ${pc.bold(asyncTaskId)}\n` +
`\n` +
` The second argument must be the ${pc.bold('asyncTaskId')} — the UUID printed after\n` +
` "→ Task" in the video/image output, not the generation ID (gen_xxx).\n` +
`\n` +
` Example output from "lh gen video":\n` +
` Generation ${pc.bold('gen_abc123')} → Task ${pc.dim('7ad0eb13-e9a5-4403-8070-1f7fe95b2f95')}\n` +
`\n` +
` Correct usage:\n` +
` ${pc.cyan(`lh gen ${command} gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`)}`
);
}
if (isGenerationNotFound) {
return (
`${pc.red('✗')} Generation not found: ${pc.bold(generationId)}\n` +
`\n` +
` The first argument must be the ${pc.bold('generationId')} (gen_xxx) from the\n` +
` video/image output.\n` +
`\n` +
` Correct usage:\n` +
` ${pc.cyan(`lh gen ${command} <generationId> <asyncTaskId>`)}`
);
}
return null;
}
export function registerGenerateCommand(program: Command) {
const generate = program
.command('generate')
@@ -23,15 +78,26 @@ export function registerGenerateCommand(program: Command) {
// ── status ──────────────────────────────────────────
generate
.command('status <generationId> <taskId>')
.command('status <generationId> <asyncTaskId>')
.description('Check generation task status')
.option('--json', 'Output raw JSON')
.action(async (generationId: string, taskId: string, options: { json?: boolean }) => {
.action(async (generationId: string, asyncTaskId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const result = await client.generation.getGenerationStatus.query({
asyncTaskId: taskId,
generationId,
});
let result: any;
try {
result = await client.generation.getGenerationStatus.query({
asyncTaskId,
generationId,
});
} catch (err: any) {
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'status');
if (msg) {
console.error(msg);
process.exit(1);
}
throw err;
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
@@ -53,7 +119,7 @@ export function registerGenerateCommand(program: Command) {
// ── download ──────────────────────────────────────────
generate
.command('download <generationId> <taskId>')
.command('download <generationId> <asyncTaskId>')
.description('Wait for generation to complete and download the result')
.option('-o, --output <path>', 'Output file path (default: auto-detect from asset)')
.option('--interval <sec>', 'Polling interval in seconds', '5')
@@ -61,7 +127,7 @@ export function registerGenerateCommand(program: Command) {
.action(
async (
generationId: string,
taskId: string,
asyncTaskId: string,
options: { interval?: string; output?: string; timeout?: string },
) => {
const client = await getTrpcClient();
@@ -73,10 +139,20 @@ export function registerGenerateCommand(program: Command) {
// Poll for completion
while (true) {
const result = (await client.generation.getGenerationStatus.query({
asyncTaskId: taskId,
generationId,
})) as any;
let result: any;
try {
result = await client.generation.getGenerationStatus.query({
asyncTaskId,
generationId,
});
} catch (err: any) {
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'download');
if (msg) {
console.error(`\n${msg}`);
process.exit(1);
}
throw err;
}
if (result.status === 'success' && result.generation) {
const gen = result.generation;
@@ -125,7 +201,7 @@ export function registerGenerateCommand(program: Command) {
console.log(
`${pc.red('✗')} Timed out after ${options.timeout}s. Task still ${result.status}.`,
);
console.log(pc.dim(`Run "lh gen status ${generationId} ${taskId}" to check later.`));
console.log(pc.dim(`Run "lh gen status ${generationId} ${asyncTaskId}" to check later.`));
process.exit(1);
}
+344
View File
@@ -0,0 +1,344 @@
import { PassThrough } from 'node:stream';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerHeteroCommand } from './hetero';
const { mockSpawnAgent } = vi.hoisted(() => ({
mockSpawnAgent: vi.fn(),
}));
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
spawnAgent: mockSpawnAgent,
}));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
/**
* Build a Promise resolving to a fake `SpawnAgentHandle`. `spawnAgent` itself
* is async, so test mocks return the handle wrapped same iterable contract,
* just behind one microtask. The async iterable yields `events` synchronously
* and ends, so the command's `for await (const event of ...)` loop terminates
* without hanging the test.
*/
const createFakeHandle = ({
events = [] as any[],
exitCode = 0,
signal = null as NodeJS.Signals | null,
stderrChunks = [] as string[],
}: {
events?: any[];
exitCode?: number | null;
signal?: NodeJS.Signals | null;
stderrChunks?: string[];
} = {}) => {
const stderr = new PassThrough();
setImmediate(() => {
for (const c of stderrChunks) stderr.write(c);
stderr.end();
});
const eventsIter: AsyncIterable<any> = {
[Symbol.asyncIterator]() {
let i = 0;
return {
async next() {
if (i < events.length) return { done: false, value: events[i++] };
return { done: true, value: undefined };
},
};
},
};
return Promise.resolve({
events: eventsIter,
exit: Promise.resolve({ code: exitCode, signal }),
kill: vi.fn(),
pid: 12_345,
stderr,
});
};
describe('hetero exec command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Stub `process.exit` so the test runner doesn't tear down — but THROW a
// sentinel rather than return, mirroring `process.exit`'s `never` return
// type in production. Without throwing, the command's code after an
// `exit(2)` keeps running and crashes on `handle.stderr` (no spawn mock).
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`__exit__${code}`);
}) as any);
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
mockSpawnAgent.mockReset();
});
afterEach(() => {
exitSpy.mockRestore();
stdoutSpy.mockRestore();
vi.restoreAllMocks();
});
/** Build a fresh program with the hetero command registered. */
const buildProgram = () => {
const program = new Command();
program.exitOverride();
registerHeteroCommand(program);
return program;
};
/**
* Run the parsed command. Swallows our `__exit__<code>` sentinel so tests
* can inspect `exitSpy.mock.calls` afterwards instead of having to wrap
* every `parseAsync` in `expect(...).rejects`. Real production exits stay
* `process.exit` so this only affects the test path.
*/
const runCmd = async (argv: string[]) => {
try {
await buildProgram().parseAsync(argv, { from: 'user' });
} catch (err) {
if (err instanceof Error && err.message.startsWith('__exit__')) return;
throw err;
}
};
it('rejects unsupported agent types via process.exit(2)', async () => {
await runCmd(['hetero', 'exec', '--type', 'kimi-cli', '--prompt', 'hi']);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockSpawnAgent).not.toHaveBeenCalled();
});
it('rejects empty prompts via process.exit(2)', async () => {
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', ' ']);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockSpawnAgent).not.toHaveBeenCalled();
});
it('passes --type / --prompt / --resume / --cwd / --command through to spawnAgent', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle());
await runCmd([
'hetero',
'exec',
'--type',
'codex',
'--prompt',
'do thing',
'--resume',
'thread_abc',
'--cwd',
'/tmp/work',
'--command',
'/usr/local/bin/codex',
]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
const call = mockSpawnAgent.mock.calls[0][0];
expect(call).toMatchObject({
agentType: 'codex',
command: '/usr/local/bin/codex',
cwd: '/tmp/work',
prompt: 'do thing',
resumeSessionId: 'thread_abc',
});
// operationId auto-generated when omitted (uuid v4 shape)
expect(call.operationId).toMatch(/^[0-9a-f-]{36}$/i);
});
it('uses the provided --operation-id verbatim', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle());
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--operation-id',
'op-server-allocated',
]);
const call = mockSpawnAgent.mock.calls[0][0];
expect(call.operationId).toBe('op-server-allocated');
});
it('streams events to stdout as JSONL, one line per event', async () => {
const events = [
{ data: { foo: 1 }, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'stream_start' },
{
data: { chunkType: 'text', content: 'hi' },
operationId: 'op-1',
stepIndex: 0,
timestamp: 2,
type: 'stream_chunk',
},
];
mockSpawnAgent.mockReturnValue(createFakeHandle({ events }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--operation-id',
'op-1',
]);
// Each event is one JSON line with a trailing \n.
const lines = stdoutSpy.mock.calls.map((c) => c[0]).filter((s) => typeof s === 'string');
expect(lines).toHaveLength(2);
for (const line of lines as string[]) {
expect(line.endsWith('\n')).toBe(true);
const parsed = JSON.parse(line);
expect(parsed.operationId).toBe('op-1');
}
});
it('passes the child exit code straight through', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle({ exitCode: 7 }));
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', 'hi']);
expect(exitSpy).toHaveBeenCalledWith(7);
});
it('maps SIGINT (code === null) to POSIX exit code 130', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle({ exitCode: null, signal: 'SIGINT' }));
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', 'hi']);
expect(exitSpy).toHaveBeenCalledWith(130);
});
it('combines --prompt + --image into mixed content blocks', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle());
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'describe',
'--image',
'./fixture-a.png',
'--image',
'https://cdn.example/fixture-b.png',
]);
const call = mockSpawnAgent.mock.calls[0][0];
expect(Array.isArray(call.prompt)).toBe(true);
expect(call.prompt).toEqual([
{ text: 'describe', type: 'text' },
// Path is resolved against process.cwd() — match by suffix to be CI-portable.
{
source: expect.objectContaining({ type: 'path' }),
type: 'image',
},
{
source: { type: 'url', url: 'https://cdn.example/fixture-b.png' },
type: 'image',
},
]);
expect(call.prompt[1].source.path).toMatch(/fixture-a\.png$/);
});
it('parses a data: URL --image into a base64 source', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle());
const dataUrl = `data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`;
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'see',
'--image',
dataUrl,
]);
const call = mockSpawnAgent.mock.calls[0][0];
expect(call.prompt[1]).toEqual({
source: {
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'),
mediaType: 'image/png',
type: 'base64',
},
type: 'image',
});
});
it('reads multimodal content from --input-json <file>', async () => {
const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const path = await import('node:path');
const dir = await mkdtemp(`${tmpdir()}/hetero-input-json-`);
const file = path.join(dir, 'input.json');
await writeFile(
file,
JSON.stringify([
{ text: 'analyze', type: 'text' },
{ source: { type: 'url', url: 'https://x/y.png' }, type: 'image' },
]),
);
mockSpawnAgent.mockReturnValue(createFakeHandle());
try {
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--input-json', file]);
} finally {
await rm(dir, { force: true, recursive: true });
}
const call = mockSpawnAgent.mock.calls[0][0];
expect(call.prompt).toEqual([
{ text: 'analyze', type: 'text' },
{ source: { type: 'url', url: 'https://x/y.png' }, type: 'image' },
]);
});
it('reports spawnAgent rejections (e.g. missing --image path) as a clean error + exit(1)', async () => {
// spawnAgent is now async and can reject during image normalization —
// missing local --image paths, fetch failures, etc. The CLI must catch
// these and exit with a friendly message instead of crashing on an
// unhandled rejection.
mockSpawnAgent.mockReturnValue(
Promise.reject(new Error('ENOENT: no such file or directory, open /missing.png')),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'see',
'--image',
'/missing.png',
]);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('rejects --prompt + --input-json (mutually exclusive)', async () => {
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--input-json',
'/tmp/bogus.json',
]);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockSpawnAgent).not.toHaveBeenCalled();
});
});
+380
View File
@@ -0,0 +1,380 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type {
AgentContentBlock,
AgentImageSource,
AgentPromptInput,
} from '@lobechat/heterogeneous-agents/spawn';
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
import type { Command } from 'commander';
import { getTrpcClient } from '../api/client';
import { BatchIngester, NoopIngestSink } from '../utils/BatchIngester';
import { log } from '../utils/logger';
import { TrpcIngestSink } from '../utils/TrpcIngestSink';
const SUPPORTED_AGENT_TYPES = new Set(['claude-code', 'codex']);
interface ExecOptions {
command?: string;
cwd?: string;
image?: string[];
inputJson?: string;
operationId?: string;
prompt?: string;
/**
* Output rendering mode.
* jsonl emit each `AgentStreamEvent` as a JSONL line on stdout (default
* when no --topic is set, or when explicitly requested).
* none suppress JSONL stdout; only server-ingest mode is active.
* Default when --topic is set and running non-interactively.
*/
render?: 'jsonl' | 'none';
resume?: string;
/**
* Server topic id. When set, enables server-ingest mode: events are
* batch-POSTed to `aiAgent.heteroIngest` in addition to (or instead of)
* being written to stdout. Requires `--operation-id` to be a valid
* server-allocated operation id.
*/
topic?: string;
type: string;
}
const collectImage = (value: string, previous: string[] = []): string[] => [...previous, value];
const readStdin = async (): Promise<string> => {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer));
}
return Buffer.concat(chunks).toString('utf8');
};
/**
* Resolve a raw `--input-json` argument: `'-'` (or empty) reads stdin, anything
* else is treated as a filesystem path.
*/
const readInputJson = async (location: string): Promise<string> => {
if (location === '-' || location === '') return readStdin();
return readFile(location, 'utf8');
};
const looksLikeJsonInput = (value: string): boolean => {
const trimmed = value.trimStart();
return trimmed.startsWith('{') || trimmed.startsWith('[');
};
/**
* Convert an `--image <value>` argument into an image source. Recognized
* shapes: `https?://...` URL, `data:` URL, otherwise a filesystem path
* resolved relative to the CLI's cwd.
*/
const parseImageArg = (value: string): AgentImageSource => {
if (/^https?:\/\//i.test(value)) return { type: 'url', url: value };
if (value.startsWith('data:')) {
const match = value.match(/^data:([^;,]+);base64,(.+)$/);
if (!match) {
throw new Error(`Invalid data URL for --image: ${value.slice(0, 40)}`);
}
return { data: match[2]!, mediaType: match[1]!, type: 'base64' };
}
return { path: path.resolve(process.cwd(), value), type: 'path' };
};
/**
* Best-effort coercion of a JSON-decoded value into an `AgentPromptInput`.
* Accepts:
* - `'plain text'` single text block
* - `[{ type: 'text', text }, { type: 'image', source }]` content blocks
* - `{ content: [...] }` (Anthropic message shape) unwraps `content`
* - `{ type: 'text', ... } | { type: 'image', ... }` single block
*/
const coerceJsonPrompt = (parsed: unknown): AgentPromptInput => {
if (typeof parsed === 'string') return parsed;
if (Array.isArray(parsed)) return parsed as AgentContentBlock[];
if (parsed && typeof parsed === 'object') {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj.content)) return obj.content as AgentContentBlock[];
if (obj.type === 'text' || obj.type === 'image') return [obj as AgentContentBlock];
}
throw new Error(
'Invalid --input-json shape: expected a string, array of content blocks, ' +
'or `{ content: [...] }` envelope.',
);
};
interface ResolvedPrompt {
/** Human-readable description for the empty-input check. */
describe: () => string;
prompt: AgentPromptInput;
}
const buildPromptFromText = (text: string, images: string[]): ResolvedPrompt => {
if (images.length === 0) {
return { describe: () => text.trim(), prompt: text };
}
const blocks: AgentContentBlock[] = [];
if (text.length > 0) blocks.push({ text, type: 'text' });
for (const image of images) {
blocks.push({ source: parseImageArg(image), type: 'image' });
}
return {
describe: () =>
blocks
.map((b) => (b.type === 'text' ? b.text.trim() : '[image]'))
.filter(Boolean)
.join(' ')
.trim(),
prompt: blocks,
};
};
/**
* Decide which input mode the user requested and produce a unified prompt.
*
* Mode resolution (mutually exclusive):
* 1. `--input-json` read JSON file or stdin, parse to content blocks
* 2. `--prompt` (with optional `--image` flags) text + images
* 3. (default) read stdin: auto-detect JSON vs plain text by first char
*/
const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
const images = options.image ?? [];
if (options.inputJson !== undefined) {
if (options.prompt !== undefined) {
throw new Error('--prompt and --input-json are mutually exclusive.');
}
if (images.length > 0) {
throw new Error('--image cannot be combined with --input-json (put images in the JSON).');
}
const raw = await readInputJson(options.inputJson);
return { describe: () => raw.trim(), prompt: coerceJsonPrompt(JSON.parse(raw)) };
}
if (options.prompt !== undefined && options.prompt !== '-') {
return buildPromptFromText(options.prompt, images);
}
// No --prompt or --prompt -: read stdin and auto-detect.
const raw = await readStdin();
if (looksLikeJsonInput(raw)) {
return { describe: () => raw.trim(), prompt: coerceJsonPrompt(JSON.parse(raw)) };
}
return buildPromptFromText(raw, images);
};
const exec = async (options: ExecOptions): Promise<void> => {
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
log.error(
`Unsupported --type "${options.type}". Supported: ${[...SUPPORTED_AGENT_TYPES].join(', ')}`,
);
process.exit(2);
}
let resolved: ResolvedPrompt;
try {
resolved = await resolvePrompt(options);
} catch (err) {
log.error(err instanceof Error ? err.message : String(err));
process.exit(2);
}
if (!resolved.describe()) {
log.error(
'Empty prompt. Pass --prompt <text>, --image <path>, --input-json <file|->, or pipe content via stdin.',
);
process.exit(2);
}
// Server-ingest mode is active when --topic is provided.
// --operation-id must be a server-allocated id in this mode (the server
// generates it before spawning the process and passes it via CLI args).
const serverIngest = !!options.topic;
if (serverIngest && !options.operationId) {
log.error('--operation-id is required when --topic is set (server-ingest mode).');
process.exit(2);
}
const operationId = options.operationId || randomUUID();
// Determine JSONL output mode.
// Explicit --render flag always wins. Otherwise: emit JSONL in standalone
// mode; suppress in server-ingest mode (sink handles the data path).
const emitJsonl = options.render === 'jsonl' || (options.render === undefined && !serverIngest);
// Build the ingest sink — no-op for standalone mode, real tRPC sink for
// server-ingest mode. The tRPC client reads LOBEHUB_JWT (operation-scoped
// JWT injected by the server) for authentication.
const agentType = options.type as 'claude-code' | 'codex';
let sink: InstanceType<typeof TrpcIngestSink> | InstanceType<typeof NoopIngestSink>;
if (serverIngest) {
const client = await getTrpcClient();
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
} else {
sink = new NoopIngestSink();
}
const ingester = new BatchIngester(sink);
// `spawnAgent` is async and can reject DURING image normalization — fetch
// failures, missing local --image paths, decode errors. Surface those as a
// clean error + exit code instead of an unhandled promise rejection / stack
// trace, mirroring the validation try/catch above.
let handle: Awaited<ReturnType<typeof spawnAgent>>;
try {
handle = await spawnAgent({
agentType: options.type,
command: options.command,
cwd: options.cwd || process.cwd(),
operationId,
prompt: resolved.prompt,
resumeSessionId: options.resume,
});
} catch (err) {
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
process.exit(1);
}
// Forward the child's stderr to ours so users see CLI errors / warnings
// (auth prompts, missing-binary errors, etc.) in the terminal.
handle.stderr.pipe(process.stderr);
// Ctrl-C → SIGINT to the child's process group so the spawned CLI gets a
// chance to clean up. Repeated Ctrl-C escalates to SIGKILL via the
// standard "double-tap" pattern most CLIs implement themselves.
// In server-ingest mode, drain the ingester and call heteroFinish before
// exiting so the server knows the operation was cancelled.
let interrupted = false;
const onSigint = async () => {
if (interrupted) {
handle.kill('SIGKILL');
return;
}
interrupted = true;
handle.kill('SIGINT');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort; process is exiting anyway
}
}
};
process.on('SIGINT', onSigint);
process.on('SIGTERM', async () => {
handle.kill('SIGTERM');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort
}
}
});
// Stream events. Each event is optionally written as JSONL and always
// pushed into the ingester (which batches and sends to the server).
let ingestError = false;
try {
for await (const event of handle.events) {
if (emitJsonl) {
process.stdout.write(`${JSON.stringify(event)}\n`);
}
ingester.push(event);
}
} catch (err) {
log.error('Stream error from agent process:', err instanceof Error ? err.message : String(err));
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({
result: 'error',
error: { message: String(err), type: 'stream_error' },
});
} catch {
// best-effort
}
}
process.exit(1);
} finally {
process.off('SIGINT', onSigint);
}
// Pass the child's exit code through. In server-ingest mode, drain the
// ingester and call heteroFinish before exiting.
const { code, signal } = await handle.exit;
if (serverIngest) {
try {
await ingester.drain();
} catch (err) {
log.error(
'Failed to flush events to server:',
err instanceof Error ? err.message : String(err),
);
ingestError = true;
}
const exitedClean = !ingestError && (code === 0 || signal === 'SIGTERM');
try {
await sink.finish({
result: exitedClean ? 'success' : 'error',
sessionId: handle.sessionId,
});
} catch (err) {
log.error('Failed to send heteroFinish:', err instanceof Error ? err.message : String(err));
}
}
if (code !== null) process.exit(ingestError ? 1 : code);
if (signal === 'SIGINT') process.exit(130);
if (signal === 'SIGTERM') process.exit(143);
if (signal === 'SIGKILL') process.exit(137);
process.exit(1);
};
export function registerHeteroCommand(program: Command) {
const hetero = program
.command('hetero')
.description('Run heterogeneous agent CLIs (Claude Code / Codex) and stream their output');
hetero
.command('exec')
.description(
'Spawn a heterogeneous agent CLI and stream its events as JSONL on stdout. Standalone mode (no server ingest).',
)
.requiredOption('-t, --type <type>', `Agent type: ${[...SUPPORTED_AGENT_TYPES].join(' | ')}`)
.option('-p, --prompt [text]', 'Prompt text. Pass `-` (or omit the value) to read from stdin.')
.option(
'-i, --image <path|url>',
'Attach an image (repeatable). Accepts a local path, http(s) URL, or data: URL.',
collectImage,
)
.option(
'--input-json <path>',
'Read full multimodal prompt as JSON content blocks from a file. Use `-` for stdin.',
)
.option('-r, --resume <sessionId>', 'Resume an existing agent session by its native id')
.option('-d, --cwd <path>', 'Working directory for the spawned agent (default: process.cwd())')
.option(
'-c, --command <bin>',
'Override the agent CLI binary name (default: `claude` or `codex`)',
)
.option(
'--operation-id <id>',
'Operation id stamped onto every emitted event. Required in server-ingest mode (--topic). Generated as a UUID if omitted (standalone).',
)
.option(
'--topic <topicId>',
'Server topic id. Enables server-ingest mode: events are batch-POSTed to aiAgent.heteroIngest. Requires --operation-id.',
)
.option(
'--render <mode>',
'Output mode: jsonl (emit events as JSONL on stdout) | none (suppress stdout). Defaults to jsonl in standalone, none in server-ingest mode.',
)
.action(exec);
}
+1 -1
View File
@@ -208,7 +208,7 @@ function readAgentProfile(workspacePath: string): AgentProfile {
// Try to extract **Emoji:** value (single emoji)
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
// Filter out placeholder text like (待定)(Chinese TBD), _(待定)_, (TBD), N/A, etc.
// Filter out placeholder text like (TBD), _(TBD)_, N/A, and Chinese-language equivalents.
const isPlaceholder =
rawAvatar && /^[_*(].*[)_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
+17
View File
@@ -83,6 +83,23 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(models, null, 2));
});
it('should filter hidden runtime-only models from JSON output', async () => {
const visibleModels = [{ displayName: 'DeepSeek V4 Pro', id: 'deepseek-v4-pro' }];
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
...visibleModels,
{
displayName: 'LobeHub Onboarding',
id: 'lobehub-onboarding-v1',
visible: false,
},
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'list', 'lobehub', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
});
});
describe('view', () => {
+5 -1
View File
@@ -5,6 +5,8 @@ import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, truncate } from '../utils/format';
import { log } from '../utils/logger';
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
export function registerModelCommand(program: Command) {
const model = program.command('model').description('Manage AI models');
@@ -33,7 +35,9 @@ export function registerModelCommand(program: Command) {
if (options.type) input.type = options.type;
const result = await client.aiModel.getAiProviderModelList.query(input as any);
let items = Array.isArray(result) ? result : ((result as any).items ?? []);
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
isVisibleModel,
);
if (options.type) {
items = items.filter((m: any) => m.type === options.type);
+1 -1
View File
@@ -145,7 +145,7 @@ export function registerReviewCommands(task: Command) {
rc.command('add <id>')
.description('Add a review rubric')
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "内容准确性")')
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "Content Accuracy")')
.option('--type <type>', 'Rubric type (default: llm-rubric)', 'llm-rubric')
.option('-t, --threshold <n>', 'Pass threshold 0-100 (converted to 0-1)')
.option('-d, --description <text>', 'Criteria description (for llm-rubric type)')
+2 -2
View File
@@ -8,12 +8,12 @@ import { registerBotCommand } from './commands/bot';
import { registerCompletionCommand } from './commands/completion';
import { registerConfigCommand } from './commands/config';
import { registerConnectCommand } from './commands/connect';
import { registerCronCommand } from './commands/cron';
import { registerDeviceCommand } from './commands/device';
import { registerDocCommand } from './commands/doc';
import { registerEvalCommand } from './commands/eval';
import { registerFileCommand } from './commands/file';
import { registerGenerateCommand } from './commands/generate';
import { registerHeteroCommand } from './commands/hetero';
import { registerKbCommand } from './commands/kb';
import { registerLoginCommand } from './commands/login';
import { registerLogoutCommand } from './commands/logout';
@@ -59,9 +59,9 @@ export function createProgram() {
registerAgentCommand(program);
registerAgentGroupCommand(program);
registerBotCommand(program);
registerCronCommand(program);
registerGenerateCommand(program);
registerFileCommand(program);
registerHeteroCommand(program);
registerSkillCommand(program);
registerSessionGroupCommand(program);
registerTaskCommand(program);
+25 -14
View File
@@ -27,22 +27,22 @@ describe('executeToolCall', () => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
it('should dispatch readLocalFile', async () => {
it('should dispatch readFile', async () => {
const filePath = path.join(tmpDir, 'test.txt');
await writeFile(filePath, 'hello world');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('hello world');
});
it('should dispatch writeLocalFile', async () => {
it('should dispatch writeFile', async () => {
const filePath = path.join(tmpDir, 'new.txt');
const result = await executeToolCall(
'writeLocalFile',
'writeFile',
JSON.stringify({ content: 'written', path: filePath }),
);
@@ -50,6 +50,17 @@ describe('executeToolCall', () => {
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
});
it('should dispatch legacy alias readLocalFile', async () => {
const filePath = path.join(tmpDir, 'legacy.txt');
await writeFile(filePath, 'legacy hello');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('legacy hello');
});
it('should dispatch runCommand', async () => {
const result = await executeToolCall(
'runCommand',
@@ -61,21 +72,21 @@ describe('executeToolCall', () => {
expect(parsed.stdout).toContain('dispatched');
});
it('should dispatch listLocalFiles', async () => {
it('should dispatch listFiles', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
const result = await executeToolCall('listLocalFiles', JSON.stringify({ path: tmpDir }));
const result = await executeToolCall('listFiles', JSON.stringify({ path: tmpDir }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.totalCount).toBeGreaterThan(0);
});
it('should dispatch globLocalFiles', async () => {
it('should dispatch globFiles', async () => {
await writeFile(path.join(tmpDir, 'test.ts'), 'code');
const result = await executeToolCall(
'globLocalFiles',
'globFiles',
JSON.stringify({ cwd: tmpDir, pattern: '*.ts' }),
);
@@ -84,12 +95,12 @@ describe('executeToolCall', () => {
expect(parsed.files).toContain('test.ts');
});
it('should dispatch editLocalFile', async () => {
it('should dispatch editFile', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'old content');
const result = await executeToolCall(
'editLocalFile',
'editFile',
JSON.stringify({
file_path: filePath,
new_string: 'new content',
@@ -116,7 +127,7 @@ describe('executeToolCall', () => {
const filePath = path.join(tmpDir, 'str.txt');
await writeFile(filePath, 'content');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
// Result should be valid JSON
@@ -124,7 +135,7 @@ describe('executeToolCall', () => {
});
it('should return error for invalid JSON arguments', async () => {
const result = await executeToolCall('readLocalFile', 'not-json');
const result = await executeToolCall('readFile', 'not-json');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
@@ -141,11 +152,11 @@ describe('executeToolCall', () => {
expect(result.success).toBe(true);
});
it('should dispatch searchLocalFiles', async () => {
it('should dispatch searchFiles', async () => {
await writeFile(path.join(tmpDir, 'search_target.txt'), 'found');
const result = await executeToolCall(
'searchLocalFiles',
'searchFiles',
JSON.stringify({ directory: tmpDir, keywords: 'search_target' }),
);
+11 -3
View File
@@ -11,14 +11,22 @@ import {
import { getCommandOutput, killCommand, runCommand } from './shell';
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
editLocalFile,
editFile: editLocalFile,
getCommandOutput,
globLocalFiles,
globFiles: globLocalFiles,
grepContent,
killCommand,
listFiles: listLocalFiles,
readFile: readLocalFile,
runCommand,
searchFiles: searchLocalFiles,
writeFile: writeLocalFile,
// Legacy aliases — older Gateway versions may still send the long form
editLocalFile,
globLocalFiles,
listLocalFiles,
readLocalFile,
runCommand,
searchLocalFiles,
writeLocalFile,
};
+99
View File
@@ -0,0 +1,99 @@
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
export interface IngestSink {
finish: (params: {
error?: { message: string; type: string };
result: 'cancelled' | 'error' | 'success';
sessionId?: string;
}) => Promise<void>;
ingest: (events: AgentStreamEvent[]) => Promise<void>;
}
export class NoopIngestSink implements IngestSink {
async finish(_params: Parameters<IngestSink['finish']>[0]): Promise<void> {}
async ingest(_events: AgentStreamEvent[]): Promise<void> {}
}
const MAX_BATCH = 50;
const FLUSH_INTERVAL_MS = 250;
const MAX_RETRIES = 5;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/**
* Buffers `AgentStreamEvent`s and flushes them in batches to an `IngestSink`.
*
* Flush triggers:
* - Buffer reaches MAX_BATCH (50) immediate flush
* - FLUSH_INTERVAL_MS (250ms) timer fires flush whatever is buffered
*
* Each batch is retried up to MAX_RETRIES (5) times with exponential back-off
* starting at 500ms, doubling up to 8s. After the final retry the error is
* stored and re-thrown by `drain()`, allowing the caller to call
* `sink.finish({ result: 'error' })` and exit(1).
*
* Call order: push() repeatedly drain() once (before finish()).
*/
export class BatchIngester {
private buffer: AgentStreamEvent[] = [];
private fatalError: Error | null = null;
private inflightFlush: Promise<void> = Promise.resolve();
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly sink: IngestSink) {}
push(event: AgentStreamEvent): void {
if (this.fatalError) return;
this.buffer.push(event);
if (this.buffer.length >= MAX_BATCH) {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.triggerFlush();
} else if (!this.timer) {
this.timer = setTimeout(() => {
this.timer = null;
this.triggerFlush();
}, FLUSH_INTERVAL_MS);
}
}
/** Flush remaining buffer and wait for all in-flight sends to settle. */
async drain(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.triggerFlush();
await this.inflightFlush;
if (this.fatalError) throw this.fatalError;
}
private async sendWithRetry(batch: AgentStreamEvent[]): Promise<void> {
let delay = 500;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
await this.sink.ingest(batch);
return;
} catch (err) {
if (attempt === MAX_RETRIES) {
this.fatalError = err instanceof Error ? err : new Error(String(err));
throw this.fatalError;
}
await sleep(delay);
delay = Math.min(delay * 2, 8_000);
}
}
}
private triggerFlush(): void {
if (this.fatalError || this.buffer.length === 0) return;
const batch = this.buffer.splice(0);
this.inflightFlush = this.inflightFlush
.then(() => this.sendWithRetry(batch))
.catch(() => {
// fatalError is already set; drain() re-throws it
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
import type { TrpcClient } from '../api/client';
import type { IngestSink } from './BatchIngester';
/**
* `IngestSink` implementation that forwards batches to the server via tRPC
* (`aiAgent.heteroIngest` / `aiAgent.heteroFinish`).
*
* The CLI authenticates using the `LOBEHUB_JWT` env var (operation-scoped JWT
* injected by the server before spawning the sandbox / desktop process).
*/
export class TrpcIngestSink implements IngestSink {
constructor(
private readonly client: TrpcClient,
private readonly agentType: 'claude-code' | 'codex',
private readonly operationId: string,
private readonly topicId: string,
) {}
async finish(params: Parameters<IngestSink['finish']>[0]): Promise<void> {
await this.client.aiAgent.heteroFinish.mutate({
agentType: this.agentType,
operationId: this.operationId,
topicId: this.topicId,
...params,
});
}
async ingest(events: AgentStreamEvent[]): Promise<void> {
await this.client.aiAgent.heteroIngest.mutate({
agentType: this.agentType,
events: events as any,
operationId: this.operationId,
topicId: this.topicId,
});
}
}
+2 -1
View File
@@ -58,6 +58,7 @@
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
"@lobechat/file-loaders": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobehub/i18n-cli": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.24.3",
@@ -74,7 +75,7 @@
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"diff": "^8.0.4",
"electron": "41.1.0",
"electron": "41.3.0",
"electron-builder": "^26.8.1",
"electron-devtools-installer": "4.0.0",
"electron-is": "^3.0.0",
+1
View File
@@ -1,6 +1,7 @@
packages:
- '../cli'
- '../../packages/agent-gateway-client'
- '../../packages/heterogeneous-agents'
- '../../packages/const'
- '../../packages/electron-server-ipc'
- '../../packages/electron-client-ipc'
@@ -7,18 +7,18 @@ import { entryLocaleJsonFilepath, i18nConfig, localeDir, srcDefaultLocales } fro
import { tagWhite, writeJSON } from './utils';
export const genDefaultLocale = () => {
consola.info(`默认语言为 ${i18nConfig.entryLocale}...`);
consola.info(`Default locale: ${i18nConfig.entryLocale}...`);
// Ensure entry locale directory exists
const entryLocaleDir = localeDir(i18nConfig.entryLocale);
if (!existsSync(entryLocaleDir)) {
mkdirSync(entryLocaleDir, { recursive: true });
consola.info(`创建目录:${entryLocaleDir}`);
consola.info(`Creating directory: ${entryLocaleDir}`);
}
const resources = require(srcDefaultLocales);
const data = Object.entries(resources.default);
consola.start(`生成默认语言 JSON 文件,发现 ${data.length} 个命名空间...`);
consola.start(`Generating default locale JSON files, found ${data.length} namespaces...`);
for (const [ns, value] of data) {
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
+3 -3
View File
@@ -13,7 +13,7 @@ import {
import { readJSON, tagWhite, writeJSON } from './utils';
export const genDiff = () => {
consola.start(`对比开发与生产环境中的本地化文件...`);
consola.start(`Comparing localization files between dev and prod environments...`);
const resources = require(srcDefaultLocales);
const data = Object.entries(resources.default);
@@ -21,7 +21,7 @@ export const genDiff = () => {
for (const [ns, devJSON] of data) {
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
if (!existsSync(filepath)) {
consola.info(`文件不存在,跳过:${filepath}`);
consola.info(`File does not exist, skipping: ${filepath}`);
continue;
}
@@ -50,7 +50,7 @@ export const genDiff = () => {
}
if (clearLocals.length > 0) {
consola.info('清理了以下语言的过期项目:', clearLocals.join(', '));
consola.info('Cleaned up stale entries for the following locales:', clearLocals.join(', '));
}
consola.success(tagWhite(ns), colors.gray(filepath));
}
+3 -3
View File
@@ -21,15 +21,15 @@ const run = async () => {
ensureLocalesDirs();
// Diff analysis
split('差异分析');
split('Diff Analysis');
genDiff();
// Generate default locale files
split('生成默认语言文件');
split('Generate Default Locale Files');
genDefaultLocale();
// Generate i18n files
split('生成国际化文件');
split('Generate i18n Files');
};
run();
+1
View File
@@ -26,6 +26,7 @@ export const defaultProxySettings: NetworkProxySettings = {
* Storage default values
*/
export const STORE_DEFAULTS: ElectronMainStore = {
appTrayVisible: true,
dataSyncConfig: { storageMode: 'cloud' },
encryptedTokens: {},
gatewayDeviceDescription: '',
@@ -1,7 +1,9 @@
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -33,6 +35,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(ShellCommandCtr);
}
private get heterogeneousAgentCtr() {
return this.app.getController(HeterogeneousAgentCtr);
}
// ─── Lifecycle ───
afterAppReady() {
@@ -47,6 +53,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
// Wire up tool call handler
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
// Wire up agent run handler
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
// Auto-connect if already logged in
this.tryAutoConnect();
}
@@ -108,23 +117,81 @@ export default class GatewayConnectionCtr extends ControllerModule {
await this.service.connect();
}
// ─── Agent Run Routing ───
private async executeAgentRun(
request: AgentRunRequestMessage,
): Promise<{ reason?: string; status: 'accepted' | 'rejected' }> {
try {
const ctr = this.heterogeneousAgentCtr;
// Create a session for the hetero agent.
const { sessionId } = await ctr.startSession({
agentType: request.agentType,
args: [],
command: request.agentType === 'codex' ? 'codex' : 'claude',
cwd: request.cwd,
// Inject LOBEHUB_JWT so the CLI authenticates against heteroIngest.
env: { LOBEHUB_JWT: request.jwt },
resumeSessionId: request.resumeSessionId,
});
// Fire-and-forget: sendPrompt runs the CLI until completion.
ctr
.sendPrompt({
operationId: request.operationId,
prompt: request.prompt,
sessionId,
})
.catch((err: Error) => {
// Errors are surfaced via heteroFinish on the server side.
// Log locally for desktop debugging only.
console.error('[GatewayConnectionCtr] agent run failed:', err.message);
});
return { status: 'accepted' };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { reason, status: 'rejected' };
}
}
// ─── Tool Call Routing ───
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
const editFile = () => this.localFileCtr.handleEditFile(args);
const globFiles = () => this.localFileCtr.handleGlobFiles(args);
const listFiles = () => this.localFileCtr.listLocalFiles(args);
const moveFiles = () => this.localFileCtr.handleMoveFiles(args);
const readFile = () => this.localFileCtr.readFile(args);
const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args);
const writeFile = () => this.localFileCtr.handleWriteFile(args);
const methodMap: Record<string, () => Promise<unknown>> = {
editLocalFile: () => this.localFileCtr.handleEditFile(args),
globLocalFiles: () => this.localFileCtr.handleGlobFiles(args),
editFile,
globFiles,
grepContent: () => this.localFileCtr.handleGrepContent(args),
listLocalFiles: () => this.localFileCtr.listLocalFiles(args),
moveLocalFiles: () => this.localFileCtr.handleMoveFiles(args),
readLocalFile: () => this.localFileCtr.readFile(args),
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
searchLocalFiles: () => this.localFileCtr.handleLocalFilesSearch(args),
writeLocalFile: () => this.localFileCtr.handleWriteFile(args),
listFiles,
moveFiles,
readFile,
searchFiles,
writeFile,
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
// Legacy aliases — keep these so older Gateway versions sending the long
// names continue to route correctly. `renameLocalFile` is also kept even
// though the new surface drops rename (it's now handled by `moveFiles`).
editLocalFile: editFile,
globLocalFiles: globFiles,
listLocalFiles: listFiles,
moveLocalFiles: moveFiles,
readLocalFile: readFile,
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
searchLocalFiles: searchFiles,
writeLocalFile: writeFile,
};
const handler = methodMap[apiName];
+758 -2
View File
@@ -1,17 +1,24 @@
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { execFile, spawn } from 'node:child_process';
import { readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import type {
GetGitBranchDiffPayload,
GitAheadBehind,
GitBranchDiffPatches,
GitBranchInfo,
GitBranchListItem,
GitCheckoutResult,
GitFileDiffStatus,
GitFileRevertResult,
GitLinkedPullRequestResult,
GitPullResult,
GitPushResult,
GitRemoteBranchListItem,
GitWorkingTreeFiles,
GitWorkingTreePatch,
GitWorkingTreePatches,
GitWorkingTreeStatus,
} from '@lobechat/electron-client-ipc';
@@ -22,6 +29,412 @@ import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:GitCtr');
interface DirtyEntry {
filePath: string;
status: GitFileDiffStatus;
}
interface DiffBlock {
isBinary: boolean;
patch: string;
/** Destination path (or source path for deleted files). */
path: string;
}
/**
* Split the output of `git diff HEAD --` into one block per file. Each block
* starts at a `^diff --git ` line and runs to just before the next one (or
* EOF). Path comes from the `+++ b/<path>` line, falling back to `--- a/<path>`
* when the destination is `/dev/null` (deletion). Quoted paths (spaces /
* non-ASCII when `core.quotepath` is on) are minimally de-escaped.
*/
const splitBulkDiff = (diffText: string): DiffBlock[] => {
if (!diffText) return [];
const blocks: DiffBlock[] = [];
const headerRe = /^diff --git /gm;
const starts: number[] = [];
let m: RegExpExecArray | null;
while ((m = headerRe.exec(diffText)) !== null) starts.push(m.index);
for (let i = 0; i < starts.length; i++) {
const start = starts[i];
const end = i + 1 < starts.length ? starts[i + 1] : diffText.length;
const block = diffText.slice(start, end);
const filePath = extractPathFromDiffBlock(block);
if (!filePath) continue;
blocks.push({
isBinary: /^Binary files .* differ$/m.test(block),
path: filePath,
patch: block,
});
}
return blocks;
};
/**
* Pull the file path out of a per-file diff block. Looks at the `+++ b/<path>`
* line first (covers add/modify); falls back to `--- a/<path>` for deletes
* where `+++` is `/dev/null`; final fallback is the `diff --git a/x b/y`
* header line.
*/
const extractPathFromDiffBlock = (block: string): string | null => {
let plusPath: string | null = null;
let minusPath: string | null = null;
for (const line of block.split('\n')) {
if (line.startsWith('+++ ')) {
plusPath = parseDiffPathLine(line.slice(4), 'b/');
} else if (line.startsWith('--- ')) {
minusPath = parseDiffPathLine(line.slice(4), 'a/');
}
// The file headers always come before the first hunk / binary marker;
// bail once we hit either to avoid scanning huge diff bodies.
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
}
if (plusPath) return plusPath;
if (minusPath) return minusPath;
// Last-resort: parse the `diff --git a/x b/y` header itself.
const header = block.split('\n', 1)[0];
const match = /^diff --git a\/.+? b\/(.+)$/.exec(header);
return match ? match[1] : null;
};
/**
* Strip the `a/` or `b/` prefix off a `+++` / `---` line, drop the optional
* trailing tab+timestamp, and de-quote git's C-style escaping. Returns null
* for `/dev/null` (which means the other side of the diff is the real path).
*/
const parseDiffPathLine = (raw: string, prefix: 'a/' | 'b/'): string | null => {
const tabIdx = raw.indexOf('\t');
let p = tabIdx >= 0 ? raw.slice(0, tabIdx) : raw;
if (p === '/dev/null') return null;
// Quoted form: "b/path with spaces"
if (p.startsWith('"') && p.endsWith('"')) {
p = dequoteGitPath(p.slice(1, -1));
}
return p.startsWith(prefix) ? p.slice(prefix.length) : p;
};
export const dequoteGitPath = (s: string): string =>
s.replaceAll(/\\(["\\trn]|[0-7]{3})/g, (_, esc: string) => {
if (esc === '"') return '"';
if (esc === '\\') return '\\';
if (esc === 't') return '\t';
if (esc === 'r') return '\r';
if (esc === 'n') return '\n';
return String.fromCodePoint(Number.parseInt(esc, 8));
});
/**
* Inverse of {@link dequoteGitPath} returns either `<prefix><path>` (when
* no escaping is needed) or git's C-style quoted form `"<prefix><escaped>"`
* (when the path contains TAB / LF / CR / quote / backslash / control bytes).
* The prefix lives *inside* the quotes so the output matches what real `git
* diff` would emit, e.g. `"a/file\twith tab.txt"` rather than `a/"file\twith
* tab.txt"`. Plain spaces are not quoted (git tolerates them; the trailing
* ` b/<path>` marker on the diff header is enough to delimit the source).
*/
// eslint-disable-next-line no-control-regex
const NEEDS_QUOTING = /["\\\x00-\x1F\x7F]/;
export const quoteGitPath = (prefix: 'a/' | 'b/', filePath: string): string => {
const combined = prefix + filePath;
if (!NEEDS_QUOTING.test(combined)) return combined;
let out = '"';
for (const ch of combined) {
if (ch === '\\') out += '\\\\';
else if (ch === '"') out += '\\"';
else if (ch === '\t') out += '\\t';
else if (ch === '\n') out += '\\n';
else if (ch === '\r') out += '\\r';
else {
const code = ch.codePointAt(0)!;
if (code < 0x20 || code === 0x7f) {
out += '\\' + code.toString(8).padStart(3, '0');
} else {
out += ch;
}
}
}
return out + '"';
};
/**
* Status from a single diff block's preamble: `new file mode` added,
* `deleted file mode` deleted, otherwise modified. Used by branch-diff mode
* where there's no `git status` to consult the diff itself is the source.
*/
const detectDiffBlockStatus = (block: string): GitFileDiffStatus => {
// Only scan up to the first hunk / binary marker so huge bodies aren't walked.
for (const line of block.split('\n')) {
if (line.startsWith('new file mode ')) return 'added';
if (line.startsWith('deleted file mode ')) return 'deleted';
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
}
return 'modified';
};
/** Walk a patch counting `+`/`-` lines while skipping `+++`/`---` headers. */
const countAddDel = (patch: string): { additions: number; deletions: number } => {
let additions = 0;
let deletions = 0;
for (const line of patch.split('\n')) {
if (line.startsWith('+++') || line.startsWith('---')) continue;
if (line.startsWith('+')) additions++;
else if (line.startsWith('-')) deletions++;
}
return { additions, deletions };
};
const emptyPatch = (entry: DirtyEntry): GitWorkingTreePatch => ({
additions: 0,
deletions: 0,
filePath: entry.filePath,
isBinary: false,
patch: '',
status: entry.status,
truncated: false,
});
const buildTrackedPatch = (
entry: DirtyEntry,
block: DiffBlock,
maxBytes: number,
): GitWorkingTreePatch => {
if (block.isBinary) {
return { ...emptyPatch(entry), isBinary: true };
}
if (block.patch.length > maxBytes) {
return { ...emptyPatch(entry), truncated: true };
}
const { additions, deletions } = countAddDel(block.patch);
return {
additions,
deletions,
filePath: entry.filePath,
isBinary: false,
patch: block.patch,
status: entry.status,
truncated: false,
};
};
/**
* Build a synthetic add-only patch for an untracked file by reading it from
* disk replaces the per-file `git diff --no-index /dev/null <file>` fork.
* Binary detection uses a NUL-byte sniff over the first 8 KB (matches what
* git itself does internally).
*/
const readUntrackedAsPatch = async (
cwd: string,
entry: DirtyEntry,
maxBytes: number,
): Promise<GitWorkingTreePatch> => {
const absolute = path.resolve(cwd, entry.filePath);
let size: number;
try {
const s = await stat(absolute);
if (!s.isFile()) return emptyPatch(entry);
size = s.size;
} catch (error: any) {
logger.debug('[readUntrackedAsPatch] stat failed', {
filePath: entry.filePath,
message: error?.message,
});
return emptyPatch(entry);
}
// Pre-quote so the path is C-style escaped wherever it lands in the synthetic
// patch — raw `entry.filePath` interpolation would emit malformed `diff --git`
// / `+++` lines for filenames containing TAB / LF / quote / backslash.
const aPath = quoteGitPath('a/', entry.filePath);
const bPath = quoteGitPath('b/', entry.filePath);
if (size === 0) {
return {
...emptyPatch(entry),
patch:
[
`diff --git ${aPath} ${bPath}`,
'new file mode 100644',
'--- /dev/null',
`+++ ${bPath}`,
].join('\n') + '\n',
};
}
// Cap the synthesized patch by *file* size, not patch size — a 200 KB file
// produces a ~200 KB patch (one `+` per line). Close enough.
if (size > maxBytes) {
return { ...emptyPatch(entry), truncated: true };
}
let buf: Buffer;
try {
buf = await readFile(absolute);
} catch (error: any) {
logger.debug('[readUntrackedAsPatch] read failed', {
filePath: entry.filePath,
message: error?.message,
});
return emptyPatch(entry);
}
const sniffEnd = Math.min(buf.length, 8192);
for (let i = 0; i < sniffEnd; i++) {
if (buf[i] === 0) return { ...emptyPatch(entry), isBinary: true };
}
const text = buf.toString('utf8');
// text.split('\n') leaves a trailing '' when the file ends with '\n';
// exclude it so the hunk header line count matches git's own output.
const rawLines = text.split('\n');
const trailingEmpty = rawLines.length > 0 && rawLines.at(-1) === '';
const lineCount = trailingEmpty ? rawLines.length - 1 : rawLines.length;
if (lineCount === 0) {
return { ...emptyPatch(entry), patch: '' };
}
const body = rawLines
.slice(0, lineCount)
.map((line) => '+' + line)
.join('\n');
// Mirror `git diff --no-index`'s "no newline at end of file" footer when the
// source had no trailing newline — keeps PatchDiff's hunk parser happy.
const noNewlineFooter = trailingEmpty ? '' : '\n\\ No newline at end of file';
const patch =
[
`diff --git ${aPath} ${bPath}`,
'new file mode 100644',
'--- /dev/null',
`+++ ${bPath}`,
`@@ -0,0 +1,${lineCount} @@`,
body,
].join('\n') +
noNewlineFooter +
'\n';
return {
additions: lineCount,
deletions: 0,
filePath: entry.filePath,
isBinary: false,
patch,
status: entry.status,
truncated: false,
};
};
/**
* Stream a git invocation's stdout via `spawn` instead of `execFile`'s
* fixed-size buffer. Replaces the bulk-diff caller's old 64 MB `maxBuffer`
* cap pipe-buffer-sized chunks accumulate in memory until the process
* exits, with no hard ceiling. SIGTERM on timeout. Resolves with the full
* stdout string; rejects with an Error carrying `stderr` and `partialStdout`
* fields so callers can salvage partial output (or fall back) on failure.
*/
const runGitCaptureStream = (cwd: string, args: string[], timeoutMs: number): Promise<string> =>
new Promise((resolve, reject) => {
const child = spawn('git', args, { cwd });
const stdoutChunks: Buffer[] = [];
let stderrBuf = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, timeoutMs);
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf8');
});
child.on('error', (err) => {
clearTimeout(timer);
reject(Object.assign(err, { stderr: stderrBuf }));
});
child.on('close', (code) => {
clearTimeout(timer);
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
if (timedOut) {
const err: any = new Error('git command timed out');
err.stderr = stderrBuf;
err.partialStdout = stdout;
return reject(err);
}
// `git diff HEAD` (without --exit-code) exits 0 even when there are
// diffs; non-zero is therefore a real error.
if (code !== 0) {
const err: any = new Error(`git exited with code ${code}`);
err.code = code;
err.stderr = stderrBuf;
err.partialStdout = stdout;
return reject(err);
}
resolve(stdout);
});
});
/**
* Last-resort per-file diff for tracked entries the bulk diff didn't cover
* either because the bulk command failed entirely or because git emitted no
* patch for a path the status step listed (rare race with concurrent writes).
* Mirrors the original per-file behavior so individual files keep their
* patches even when the bulk fast-path is unavailable.
*/
const fetchTrackedPatchPerFile = async (
cwd: string,
entry: DirtyEntry,
maxBytes: number,
): Promise<GitWorkingTreePatch> => {
const execFileAsync = promisify(execFile);
let text: string;
try {
const { stdout } = await execFileAsync(
'git',
['-c', 'core.quotepath=off', 'diff', '--no-color', 'HEAD', '--', entry.filePath],
{
cwd,
encoding: 'utf8',
maxBuffer: maxBytes * 4,
timeout: 10_000,
},
);
text = stdout as string;
} catch (error: any) {
logger.debug('[fetchTrackedPatchPerFile] diff failed', {
filePath: entry.filePath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return emptyPatch(entry);
}
if (text.length > maxBytes) return { ...emptyPatch(entry), truncated: true };
if (/^Binary files .* differ$/m.test(text)) return { ...emptyPatch(entry), isBinary: true };
if (!text) return emptyPatch(entry);
const { additions, deletions } = countAddDel(text);
return {
additions,
deletions,
filePath: entry.filePath,
isBinary: false,
patch: text,
status: entry.status,
truncated: false,
};
};
/**
* Bounded `Promise.all` runs at most `limit` async tasks at a time. Used
* for the per-file fallback so we cap fork pressure at a small constant
* instead of replaying the original 200-parallel `git diff` storm.
*/
const mapWithConcurrency = async <T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> => {
const results: R[] = Array.from({ length: items.length });
let cursor = 0;
const workerCount = Math.min(limit, items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const idx = cursor++;
if (idx >= items.length) return;
results[idx] = await fn(items[idx]);
}
}),
);
return results;
};
export default class GitController extends ControllerModule {
static override readonly groupName = 'git';
@@ -162,6 +575,54 @@ export default class GitController extends ControllerModule {
}
}
/**
* List remote branches under `refs/remotes/origin/*`, ordered by most
* recent commit. The `HEAD` symref is filtered out and the resolved
* default branch is flagged via `isDefault` so the UI can render it
* with a marker. Used by the Review panel's branch-compare picker.
*/
@IpcMethod()
async listGitRemoteBranches(dirPath: string): Promise<GitRemoteBranchListItem[]> {
const execFileAsync = promisify(execFile);
let defaultRef: string | undefined;
try {
const { stdout } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
defaultRef = stdout.trim() || undefined;
} catch {
defaultRef = undefined;
}
try {
const { stdout } = await execFileAsync(
'git',
[
'for-each-ref',
'--sort=-committerdate',
'--format=%(refname:short)',
'refs/remotes/origin',
],
{ cwd: dirPath, timeout: 5000 },
);
return stdout
.replaceAll('\r', '')
.split('\n')
.map((line) => line.trim())
.filter((name) => name.length > 0 && name !== 'origin/HEAD' && !name.endsWith('/HEAD'))
.map((name) => ({ isDefault: name === defaultRef, name }));
} catch (error: any) {
logger.warn('[listGitRemoteBranches] git command failed', {
code: error?.code,
cwd: dirPath,
message: error?.message,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return [];
}
}
/**
* Bucket dirty files into added / modified / deleted via `git status --porcelain -z`.
* Each file is counted once: untracked (`??`) and staged-add (`A`) added,
@@ -261,6 +722,235 @@ export default class GitController extends ControllerModule {
}
}
/**
* Pull every dirty file's unified diff in one shot one IPC call returns
* the patches the renderer needs to render `<PatchDiff />` per file.
*
* Tracked changes (modified / deleted / staged-A) all come from a *single*
* `git diff HEAD --` invocation that we split per-file in JS fork-bombing
* the main process with N parallel `git diff` subprocesses was costing us
* ~510ms × N in fork overhead plus `.git/index` lock contention, and the
* libuv worker pool stayed busy while other IPC handlers queued. One
* subprocess instead of N keeps the freeze invisible.
*
* Untracked files are read directly with `fs.readFile` and a synthetic
* `--- /dev/null / +++ b/<path>` patch is built in Node no `git diff`
* subprocess at all.
*
* Per-file patches are capped at 256 KB; oversized or binary entries get an
* empty `patch` string and a flag the renderer can use for a placeholder.
*/
@IpcMethod()
async getGitWorkingTreePatches(dirPath: string): Promise<GitWorkingTreePatches> {
const MAX_PATCH_BYTES = 256 * 1024;
const execFileAsync = promisify(execFile);
interface Entry {
filePath: string;
isUntracked: boolean;
status: GitFileDiffStatus;
}
// Step 1 — classify every dirty path. Mirrors getGitWorkingTreeFiles but
// also distinguishes untracked (`??`) from staged-add (`A`) so we can pick
// the right path (git diff vs raw read) per entry.
const entries: Entry[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 3) continue;
const x = entry[0];
const y = entry[1];
const filePath = entry.slice(3);
// R/C entries carry an extra source-path token we must consume.
if (x === 'R' || x === 'C') i++;
if (!filePath) continue;
if (x === '?' && y === '?') {
entries.push({ filePath, isUntracked: true, status: 'added' });
} else if (x === '!' && y === '!') {
// ignored
} else if (x === 'D' || y === 'D') {
entries.push({ filePath, isUntracked: false, status: 'deleted' });
} else if (x === 'A' || y === 'A') {
entries.push({ filePath, isUntracked: false, status: 'added' });
} else {
entries.push({ filePath, isUntracked: false, status: 'modified' });
}
}
} catch (error: any) {
logger.warn('[getGitWorkingTreePatches] status failed', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return { patches: [] };
}
// Step 2a — single bulk `git diff HEAD` for every tracked dirty path,
// then split per-file in JS. We pass paths explicitly (not all) so a
// huge unrelated working tree doesn't pull extra patches into the
// stream. Output is streamed via spawn so there's no maxBuffer ceiling
// — even a multi-hundred-MB combined diff lands intact, and any partial
// output recovered from a failed run still feeds the per-file fallback.
const trackedEntries = entries.filter((e) => !e.isUntracked);
const trackedByPath = new Map(trackedEntries.map((e) => [e.filePath, e]));
const trackedPatches = new Map<string, GitWorkingTreePatch>();
if (trackedEntries.length > 0) {
let bulkDiff = '';
try {
bulkDiff = await runGitCaptureStream(
dirPath,
[
'-c',
'core.quotepath=off',
'diff',
'--no-color',
'HEAD',
'--',
...trackedEntries.map((e) => e.filePath),
],
30_000,
);
} catch (error: any) {
logger.warn('[getGitWorkingTreePatches] bulk diff failed; per-file fallback', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
// Salvage any patches that did stream through before the failure —
// the per-file fallback below only retries the stragglers.
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
}
for (const block of splitBulkDiff(bulkDiff)) {
const entry = trackedByPath.get(block.path);
if (!entry) continue;
trackedPatches.set(entry.filePath, buildTrackedPatch(entry, block, MAX_PATCH_BYTES));
}
// Anything the bulk diff didn't cover (bulk crashed, race-with-write,
// or git emitted no patch for a path status flagged dirty) gets a
// per-file retry. Concurrency-capped to avoid the original fork storm.
const stragglers = trackedEntries.filter((e) => !trackedPatches.has(e.filePath));
if (stragglers.length > 0) {
const recovered = await mapWithConcurrency(stragglers, 8, (entry) =>
fetchTrackedPatchPerFile(dirPath, entry, MAX_PATCH_BYTES),
);
for (const patch of recovered) trackedPatches.set(patch.filePath, patch);
}
}
// Step 2b — read untracked files directly in Node. fs.readFile is bounded
// by libuv's thread pool (4 by default) so unbounded Promise.all is fine.
const untrackedEntries = entries.filter((e) => e.isUntracked);
const untrackedPatches = await Promise.all(
untrackedEntries.map((entry) => readUntrackedAsPatch(dirPath, entry, MAX_PATCH_BYTES)),
);
// Step 3 — combine + sort to match the working-tree popover order.
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
const allPatches: GitWorkingTreePatch[] = [...trackedPatches.values(), ...untrackedPatches];
allPatches.sort((a, b) => order[a.status] - order[b.status]);
return { patches: allPatches };
}
/**
* Diff every changed file between the current HEAD and the remote default
* branch (resolved via `refs/remotes/origin/HEAD` typically `origin/main`
* or `origin/canary`). Uses `<base>...HEAD` so the result is "what this
* branch added since it forked", ignoring upstream-only commits.
*
* Best-effort `git fetch` first so the comparison reflects the latest
* remote state; fetch failures (offline / no creds / no `origin`) are
* swallowed and we fall back to whatever cached refs exist. Returns
* `baseRef: undefined` + empty patches when no remote default is set
* the renderer surfaces a "noBaseRef" hint in that case.
*
* Patch parsing reuses the same bulk-split + size-cap path as the working
* tree variant; status comes from each diff block's preamble (no `git
* status` cross-reference needed since every block is from history).
*/
@IpcMethod()
async getGitBranchDiff(payload: GetGitBranchDiffPayload): Promise<GitBranchDiffPatches> {
const { path: dirPath, baseRef: baseRefOverride } = payload;
const MAX_PATCH_BYTES = 256 * 1024;
const execFileAsync = promisify(execFile);
// Step 1 — best-effort fetch so origin/<default> reflects remote HEAD.
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to cached refs
}
// Step 2 — pick the comparison base. When the caller passes an explicit
// override (e.g. user picked a non-default branch in the UI) we trust it;
// otherwise we resolve `refs/remotes/origin/HEAD`. The default may be
// missing on repos cloned with --no-checkout or after a remote rename —
// surface a "noBaseRef" empty state in that case so the user can run
// `git remote set-head origin --auto` themselves.
let baseRef: string | undefined = baseRefOverride;
if (!baseRef) {
try {
const { stdout } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
baseRef = stdout.trim() || undefined;
} catch {
baseRef = undefined;
}
}
// headRef populated even when baseRef is missing so the UI can still
// surface "fix/foo ← ?" instead of going completely blank.
const headRef = (await this.getGitBranch(dirPath)).branch;
if (!baseRef) {
return { headRef, patches: [] };
}
// Step 3 — single bulk diff against the merge base. Three-dot semantics
// (`base...HEAD`) ignore commits added to base after the branch forked,
// matching what users expect from "compare branch" UI on GitHub. Stream
// capture mirrors the working-tree path so multi-MB diffs land intact.
let bulkDiff = '';
try {
bulkDiff = await runGitCaptureStream(
dirPath,
['-c', 'core.quotepath=off', 'diff', '--no-color', `${baseRef}...HEAD`],
30_000,
);
} catch (error: any) {
logger.warn('[getGitBranchDiff] diff failed', {
baseRef,
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
}
// Step 4 — split + classify per-file from the diff preamble alone.
const patches: GitWorkingTreePatch[] = [];
for (const block of splitBulkDiff(bulkDiff)) {
const status = detectDiffBlockStatus(block.patch);
patches.push(buildTrackedPatch({ filePath: block.path, status }, block, MAX_PATCH_BYTES));
}
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
patches.sort((a, b) => order[a.status] - order[b.status]);
return { baseRef, headRef, patches };
}
/**
* Count commits HEAD is ahead/behind its upstream tracking ref.
* Returns `hasUpstream: false` when the branch has no upstream configured
@@ -417,4 +1107,70 @@ export default class GitController extends ControllerModule {
return { error: stderr || 'git push failed', success: false };
}
}
/**
* Revert a single working-tree change. Mirrors what "Discard changes" does
* in GitHub Desktop / VSCode SCM: restore the file to its HEAD state,
* dropping any unstaged / staged edits and physically delete the file
* when it doesn't exist at HEAD (untracked or staged-add).
*
* Branch logic by HEAD presence:
* - present at HEAD `git checkout HEAD -- <file>` (covers modified,
* deleted, staged-D restores both index + worktree from HEAD)
* - absent at HEAD `git rm --cached` (unstage if staged-A; silent
* no-op for untracked) + `fs.rm` to delete the file from disk
*
* filePath is the repo-relative path from `git status` output, the same
* shape we hand to the renderer in `GitWorkingTreePatch.filePath`. We
* reject absolute paths and `..` traversal so the renderer can't poke
* outside the repo even if its payload were tampered with.
*/
@IpcMethod()
async revertGitFile(payload: { filePath: string; path: string }): Promise<GitFileRevertResult> {
const { path: dirPath, filePath } = payload;
if (!filePath?.trim()) return { error: 'File path is required', success: false };
if (path.isAbsolute(filePath) || filePath.split(/[/\\]/).includes('..')) {
return { error: `Invalid file path: ${filePath}`, success: false };
}
const execFileAsync = promisify(execFile);
// Probe HEAD via cat-file -e — exit 0 means the blob exists at HEAD.
let existsAtHead: boolean;
try {
await execFileAsync('git', ['cat-file', '-e', `HEAD:${filePath}`], {
cwd: dirPath,
timeout: 5000,
});
existsAtHead = true;
} catch {
existsAtHead = false;
}
try {
if (existsAtHead) {
await execFileAsync('git', ['checkout', 'HEAD', '--', filePath], {
cwd: dirPath,
timeout: 15_000,
});
} else {
// Unstage if the file is in the index (staged-add). `git rm --cached`
// exits non-zero on untracked paths, which is fine — swallow it.
try {
await execFileAsync('git', ['rm', '--cached', '--quiet', '--', filePath], {
cwd: dirPath,
timeout: 5000,
});
} catch {
// not staged — fall through to the disk-delete
}
await rm(path.resolve(dirPath, filePath), { force: true, recursive: false });
}
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
logger.debug('[revertGitFile] failed', { filePath, stderr });
return { error: stderr || 'git revert failed', success: false };
}
}
}
@@ -1,9 +1,12 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { unlinkSync } from 'node:fs';
import { access, appendFile, mkdir, unlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { Readable, Writable } from 'node:stream';
import { finished as streamFinished } from 'node:stream/promises';
import type { HeterogeneousAgentSessionError } from '@lobechat/electron-client-ipc';
import {
@@ -13,14 +16,19 @@ import {
CODEX_CLI_INSTALL_DOCS_URL,
HeterogeneousAgentSessionErrorCode,
} from '@lobechat/electron-client-ipc';
import type { AskUserBridge } from '@lobechat/heterogeneous-agents/askUser';
import { AskUserMcpServer } from '@lobechat/heterogeneous-agents/askUser';
import type { AgentContentBlock } from '@lobechat/heterogeneous-agents/spawn';
import {
AgentStreamPipeline,
buildAgentInput,
materializeImageToPath,
normalizeImage,
} from '@lobechat/heterogeneous-agents/spawn';
import { app as electronApp, BrowserWindow } from 'electron';
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
import { CodexFileChangeTracker } from '@/modules/heterogeneousAgent/codexFileChangeTracker';
import type {
HeterogeneousAgentImageAttachment,
HeterogeneousAgentParsedOutput,
} from '@/modules/heterogeneousAgent/types';
import type { HeterogeneousAgentImageAttachment } from '@/modules/heterogeneousAgent/types';
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
import { createLogger } from '@/utils/logger';
@@ -52,16 +60,6 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
/** Directory under appStoragePath for caching downloaded files */
const FILE_CACHE_DIR = 'heteroAgent/files';
const CLI_TRACE_DIR = '.heerogeneous-tracing';
const IMAGE_EXTENSIONS_BY_MIME = {
'image/gif': '.gif',
'image/jpg': '.jpg',
'image/jpeg': '.jpg',
'image/pjpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/x-png': '.png',
} as const satisfies Record<string, string>;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
const CODEX_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+(?:DEBUG|ERROR|INFO|TRACE|WARN)\s+/;
@@ -91,6 +89,12 @@ interface StartSessionResult {
interface SendPromptParams {
/** Image attachments to include in the prompt (downloaded from url, cached by id) */
imageList?: HeterogeneousAgentImageAttachment[];
/**
* Renderer-side operation id stamped onto every emitted `AgentStreamEvent`.
* Required: producer-side conversion is the V3 contract by the time events
* reach the renderer they must already carry the operation they belong to.
*/
operationId: string;
prompt: string;
sessionId: string;
}
@@ -99,6 +103,18 @@ interface CancelSessionParams {
sessionId: string;
}
interface SubmitInterventionParams {
cancelled?: boolean;
/** When set, signals user-cancelled or timeout — the bridge resolves with isError. */
cancelReason?: 'timeout' | 'user_cancelled';
/** Operation id stamped on the request the renderer is responding to. */
operationId: string;
/** Structured user answer; ignored when `cancelled` is true. */
result?: unknown;
/** Correlation key carried on the original `agent_intervention_request`. */
toolCallId: string;
}
interface StopSessionParams {
sessionId: string;
}
@@ -148,12 +164,30 @@ interface CliTraceSession {
* prompt transport, resume semantics, and raw stream shape without turning
* this controller into a giant `switch`.
*
* Lifecycle: startSession sendPrompt (heteroAgentRawLine broadcasts) stopSession
* Lifecycle: startSession sendPrompt (heteroAgentEvent broadcasts) stopSession
*/
interface InterventionSlot {
bridge: AskUserBridge;
/** Resolves once bridge.events() iterator ends (after `cancelAll`). */
pumpDone: Promise<void>;
/** Path to the per-op temp `mcp.json` we wrote for `--mcp-config`. */
tmpConfigPath: string;
}
export default class HeterogeneousAgentCtr extends ControllerModule {
static override readonly groupName = 'heterogeneousAgent';
private sessions = new Map<string, AgentSession>();
/**
* Per-operation AskUserQuestion bridge state. Keyed by `operationId` so the
* `submitIntervention` IPC can route an answer to the right pending MCP
* handler regardless of which `sessionId` it belongs to (one session can
* fire many ops over its lifetime).
*/
private opIdToIntervention = new Map<string, InterventionSlot>();
/** Lazy single MCP server, started on first claude-code prompt. */
private askUserMcpServer?: AskUserMcpServer;
private askUserMcpStartPromise?: Promise<AskUserMcpServer>;
private resolveSessionCommand(session: AgentSession): string {
const resolvedCommand = session.command.trim();
@@ -567,6 +601,92 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
}
// ─── AskUserQuestion MCP server (LOBE-8725) ───
/**
* Lazy single-instance MCP server for CC's AskUserQuestion replacement.
* First claude-code prompt triggers `start()`; subsequent prompts reuse
* the same listener. Concurrent first-callers de-dupe via the in-flight
* promise so we don't bind two ports.
*/
private async ensureAskUserMcpServerStarted(): Promise<AskUserMcpServer> {
if (this.askUserMcpServer) return this.askUserMcpServer;
if (!this.askUserMcpStartPromise) {
this.askUserMcpStartPromise = (async () => {
const server = new AskUserMcpServer();
await server.start();
this.askUserMcpServer = server;
logger.info('AskUserQuestion MCP server started:', server.url);
return server;
})().catch((err) => {
// Reset so a later sendPrompt can retry; surface the error.
this.askUserMcpStartPromise = undefined;
logger.error('Failed to start AskUserQuestion MCP server:', err);
throw err;
});
}
return this.askUserMcpStartPromise;
}
/**
* Register a per-op AskUserQuestion bridge, write its temp `mcp.json`,
* and start pumping the bridge's outbound events into the regular
* `heteroAgentEvent` broadcast. Caller must invoke the returned cleanup
* after the spawn finishes (success, error, or cancel) to remove the
* temp file and tear down the bridge.
*
* Pump errors are logged but never thrown they don't fail the spawn.
*/
private async setupInterventionForOp(
operationId: string,
sessionId: string,
): Promise<{ cleanup: () => Promise<void>; tmpConfigPath: string }> {
const server = await this.ensureAskUserMcpServerStarted();
const bridge = server.registerOperation(operationId);
const tmpConfigPath = path.join(os.tmpdir(), `lobe-cc-mcp-${operationId}.json`);
// `alwaysLoad: true` is the undocumented CC flag that promotes our
// server's tool out of the deferred set so the model calls it directly
// (no ToolSearch hop). See LOBE-8725 spike notes — falls back to the
// 2-hop ToolSearch path if a future CC drops the flag, no breakage.
const config = {
mcpServers: {
lobe_cc: {
alwaysLoad: true,
type: 'http' as const,
url: server.urlForOperation(operationId),
},
},
};
await writeFile(tmpConfigPath, JSON.stringify(config), 'utf8');
// Pump bridge.events() into the `heteroAgentEvent` broadcast. The
// iterator only ends after `cancelAll()`, so `pumpDone` resolves at
// cleanup time and gates teardown.
const pumpDone = (async () => {
for await (const event of bridge.events()) {
this.broadcast('heteroAgentEvent', { event, sessionId });
}
})().catch((err) => {
logger.warn('AskUserQuestion bridge pump error:', err);
});
this.opIdToIntervention.set(operationId, { bridge, pumpDone, tmpConfigPath });
const cleanup = async () => {
// Unregistering on the server cancels all bridge pendings AND closes
// the events iterator (cancelAll fires from within unregisterOperation).
this.askUserMcpServer?.unregisterOperation(operationId);
await pumpDone;
this.opIdToIntervention.delete(operationId);
await unlink(tmpConfigPath).catch(() => {
/* file may already be gone if app crashed mid-prompt */
});
};
return { cleanup, tmpConfigPath };
}
// ─── File cache ───
private get fileCacheDir(): string {
@@ -574,125 +694,56 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
/**
* Derive a filesystem-safe cache key for attachments.
*
* Never use the raw image id as a path segment upstream callers can persist
* arbitrary ids and path.join would treat traversal sequences as real
* directories. A stable hash preserves cache hits without trusting the id as a
* filename.
* Convert a desktop image attachment list into shared content blocks. Each
* attachment's id is preserved as the cache key so repeated prompts hit the
* same on-disk entries.
*/
private getImageCacheKey(imageId: string): string {
return createHash('sha256').update(imageId).digest('hex');
private toImageContentBlocks(
imageList: HeterogeneousAgentImageAttachment[],
): AgentContentBlock[] {
return imageList.map((image) => ({
source: { id: image.id, type: 'url', url: image.url },
type: 'image',
}));
}
/**
* Download an image by URL, with local disk cache keyed by id.
* Build a Claude Code stream-json user message with text + base64 images.
* Delegates to the shared `buildAgentInput`; the desktop wrapper exists only
* to preserve the helper signature consumed by existing drivers.
*/
private async resolveImage(
image: HeterogeneousAgentImageAttachment,
): Promise<{ buffer: Buffer; mimeType: string }> {
const cacheDir = this.fileCacheDir;
const cacheKey = this.getImageCacheKey(image.id);
const metaPath = path.join(cacheDir, `${cacheKey}.meta`);
const dataPath = path.join(cacheDir, cacheKey);
private async buildStreamJsonInput(
prompt: string,
imageList: HeterogeneousAgentImageAttachment[] = [],
): Promise<string> {
const blocks: AgentContentBlock[] = [];
if (prompt && prompt.length > 0) blocks.push({ text: prompt, type: 'text' });
blocks.push(...this.toImageContentBlocks(imageList));
// Check cache first
try {
const metaRaw = await readFile(metaPath, 'utf8');
const meta = JSON.parse(metaRaw);
const buffer = await readFile(dataPath);
logger.debug('Image cache hit:', image.id);
return { buffer, mimeType: meta.mimeType || 'image/png' };
} catch {
// Cache miss — download
}
logger.info('Downloading image:', image.id);
const res = await fetch(image.url);
if (!res.ok)
throw new Error(`Failed to download image ${image.id}: ${res.status} ${res.statusText}`);
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const mimeType = res.headers.get('content-type') || 'image/png';
// Write to cache
await mkdir(cacheDir, { recursive: true });
await writeFile(dataPath, buffer);
await writeFile(metaPath, JSON.stringify({ id: image.id, mimeType }));
logger.debug('Image cached:', image.id, `${buffer.length} bytes`);
return { buffer, mimeType };
}
private normalizeMimeType(mimeType: string): string {
return mimeType.split(';')[0]?.trim().toLowerCase() || '';
}
private guessImageExtensionByBuffer(buffer: Buffer): string | undefined {
if (buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return '.png';
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
const gifSignature = buffer.subarray(0, 6).toString('ascii');
if (gifSignature === 'GIF87a' || gifSignature === 'GIF89a') return '.gif';
if (
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return '.webp';
}
}
private guessImageExtension(
mimeType: string,
image: HeterogeneousAgentImageAttachment,
buffer: Buffer,
): string | undefined {
const knownByMime = IMAGE_EXTENSIONS_BY_MIME[this.normalizeMimeType(mimeType)];
if (knownByMime) return knownByMime;
try {
const pathname = new URL(image.url).pathname;
const ext = path.extname(pathname).toLowerCase();
if (ext) return ext === '.jpeg' ? '.jpg' : ext;
} catch {
// Fall through to byte sniffing below.
}
return this.guessImageExtensionByBuffer(buffer);
const plan = await buildAgentInput('claude-code', blocks, { cacheDir: this.fileCacheDir });
return plan.stdin;
}
/**
* Materialize an image attachment into a stable local file path so CLIs like
* Codex can consume it through `--image <file>`.
* Materialize image attachments into stable filesystem paths for path-mode
* agents (Codex `--image <file>`). Fails the prompt if any image cannot be
* fetched / decoded partially-attached prompts confuse the agent more
* than they help.
*/
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
const { buffer, mimeType } = await this.resolveImage(image);
const cacheKey = this.getImageCacheKey(image.id);
const ext = this.guessImageExtension(mimeType, image, buffer);
if (!ext) {
throw new Error(`Unsupported image type for ${image.id}`);
}
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
try {
await access(filePath);
} catch {
await mkdir(this.fileCacheDir, { recursive: true });
await writeFile(filePath, buffer);
}
return filePath;
}
private async resolveCliImagePaths(
imageList: HeterogeneousAgentImageAttachment[] = [],
): Promise<string[]> {
if (imageList.length === 0) return [];
const cacheDir = this.fileCacheDir;
const results = await Promise.allSettled(
imageList.map((image) => this.resolveCliImagePath(image)),
imageList.map(async (image) => {
const normalized = await normalizeImage(
{ id: image.id, type: 'url', url: image.url },
{ cacheDir },
);
return materializeImageToPath(normalized, cacheDir);
}),
);
const imagePaths: string[] = [];
@@ -718,37 +769,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
return imagePaths;
}
/**
* Build a stream-json user message with text + optional image content blocks.
*/
private async buildStreamJsonInput(
prompt: string,
imageList: HeterogeneousAgentImageAttachment[] = [],
): Promise<string> {
const content: any[] = [{ text: prompt, type: 'text' }];
for (const image of imageList) {
try {
const { buffer, mimeType } = await this.resolveImage(image);
content.push({
source: {
data: buffer.toString('base64'),
media_type: mimeType,
type: 'base64',
},
type: 'image',
});
} catch (err) {
logger.error(`Failed to resolve image ${image.id}:`, err);
}
}
return `${JSON.stringify({
message: { content, role: 'user' },
type: 'user',
})}\n`;
}
// ─── IPC methods ───
/**
@@ -779,8 +799,9 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
/**
* Send a prompt to an agent session.
*
* Spawns the CLI process with preset flags. Broadcasts each stdout line
* as an `heteroAgentRawLine` event Renderer side parses and adapts.
* Spawns the CLI process with preset flags. Pipes each stdout chunk through
* the shared `AgentStreamPipeline` (JSONL adapter toStreamEvent) and
* broadcasts the resulting `AgentStreamEvent`s on `heteroAgentEvent`.
*/
@IpcMethod()
async sendPrompt(params: SendPromptParams): Promise<void> {
@@ -796,32 +817,58 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
throw new Error(preflightError.message);
}
const driver = getHeterogeneousAgentDriver(session.agentType);
const spawnPlan = await driver.buildSpawnPlan({
args: session.args,
helpers: {
buildClaudeStreamJsonInput: (prompt, imageList) =>
this.buildStreamJsonInput(prompt, imageList),
resolveCliImagePaths: (imageList) => this.resolveCliImagePaths(imageList),
},
imageList: params.imageList ?? [],
prompt: params.prompt,
resumeSessionId: session.agentSessionId,
});
// Stand up the AskUserQuestion MCP bridge for claude-code prompts BEFORE
// building the spawn plan so the driver can wire the temp config path
// into `--mcp-config`. Codex / future agents skip this entirely.
const intervention =
session.agentType === 'claude-code'
? await this.setupInterventionForOp(params.operationId, session.sessionId).catch((err) => {
logger.warn('Failed to set up AskUserQuestion bridge — proceeding without it:', err);
return undefined;
})
: undefined;
let spawnPlan;
let traceSession;
let cwd: string;
try {
const driver = getHeterogeneousAgentDriver(session.agentType);
spawnPlan = await driver.buildSpawnPlan({
args: session.args,
helpers: {
buildClaudeStreamJsonInput: (prompt, imageList) =>
this.buildStreamJsonInput(prompt, imageList),
resolveCliImagePaths: (imageList) => this.resolveCliImagePaths(imageList),
},
imageList: params.imageList ?? [],
mcpConfigPath: intervention?.tmpConfigPath,
prompt: params.prompt,
resumeSessionId: session.agentSessionId,
});
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
cwd = session.cwd || electronApp.getPath('desktop');
traceSession = await this.createCliTraceSession({
cliArgs: spawnPlan.args,
cwd,
imageList: params.imageList ?? [],
session,
stdinPayload: spawnPlan.stdinPayload,
});
} catch (err) {
// We never made it to spawn — the `proc.on('exit')` cleanup path
// won't run, so tear the intervention bridge down right here.
if (intervention) {
await intervention.cleanup().catch((cleanupErr) => {
logger.warn('AskUserQuestion cleanup error during pre-spawn failure:', cleanupErr);
});
}
throw err;
}
const useStdin = spawnPlan.stdinPayload !== undefined;
const cliArgs = spawnPlan.args;
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
const cwd = session.cwd || electronApp.getPath('desktop');
const traceSession = await this.createCliTraceSession({
cliArgs,
cwd,
imageList: params.imageList ?? [],
session,
stdinPayload: spawnPlan.stdinPayload,
});
return new Promise<void>((resolve, reject) => {
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
@@ -852,42 +899,49 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
session.process = proc;
const streamProcessor = driver.createStreamProcessor();
const codexFileChangeTracker =
session.agentType === 'codex' ? new CodexFileChangeTracker() : undefined;
// Producer-side conversion (V3 contract): JSONL framing + adapter +
// toStreamEvent all run inside the shared pipeline, so renderer + future
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
// no per-consumer adapter. The pipeline auto-wires the Codex
// file-change line-stat tracker when `agentType === 'codex'`, so this
// controller stays agent-agnostic.
const pipeline = new AgentStreamPipeline({
agentType: session.agentType,
operationId: params.operationId,
});
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
const broadcastParsedOutputs = (parsedOutputs: HeterogeneousAgentParsedOutput[]) => {
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
stdoutBroadcastQueue = stdoutBroadcastQueue
.then(async () => {
for (const parsedOutput of parsedOutputs) {
if (parsedOutput.agentSessionId) {
session.agentSessionId = parsedOutput.agentSessionId;
}
const line = codexFileChangeTracker
? await codexFileChangeTracker.track(parsedOutput.payload)
: parsedOutput.payload;
this.broadcast('heteroAgentRawLine', {
line,
const events = await produce();
// Adapter-extracted CC/Codex session id powers `--resume` on the
// next prompt; surface it through the existing `getSessionInfo`
// IPC by mirroring the freshest value onto the session record.
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
session.agentSessionId = pipeline.sessionId;
}
for (const event of events) {
this.broadcast('heteroAgentEvent', {
event,
sessionId: session.sessionId,
});
}
})
.catch((error) => {
logger.error('Failed to broadcast parsed agent output:', error);
logger.error('Failed to broadcast agent stream batch:', error);
});
};
// Stream stdout events as raw provider payloads to Renderer.
// Stream stdout events through the producer pipeline.
const stdout = proc.stdout as Readable;
stdout.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
broadcastParsedOutputs(streamProcessor.push(chunk));
broadcastPipelineBatch(() => pipeline.push(chunk));
});
stdout.on('end', () => {
broadcastParsedOutputs(streamProcessor.flush());
broadcastPipelineBatch(() => pipeline.flush());
});
// Capture stderr
@@ -914,44 +968,68 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
});
proc.on('exit', (code, signal) => {
void stdoutBroadcastQueue.finally(async () => {
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
});
await this.flushCliTrace(traceSession);
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
// exit as a clean shutdown — surfacing it as an error would make a
// user-initiated cancel look like an agent failure, and an Electron
// shutdown affecting OTHER running CC sessions would pollute their
// topics with a misleading "Agent exited with code 143" message.
if (session.cancelledByUs) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
return;
}
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
const sessionError = this.getSessionErrorPayload(errorMsg, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
);
}
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
// child_process docs note "stdio streams might still be open" at exit
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
// THEN wait for the queue itself to settle. Without this two-step
// gate, trailing flushed events (final synthesized tool_end /
// tool_result) would race against — and lose to — the
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
// persistence to finalize on incomplete state.
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
/* end / close / error are all "done"; we still want to settle. */
});
void stdoutDrained
.then(() => stdoutBroadcastQueue)
.finally(async () => {
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
// op. Pending MCP handlers get a `session_ended` cancellation so
// they return cleanly even if CC was killed mid-tool-call.
if (intervention) {
await intervention.cleanup().catch((err) => {
logger.warn('AskUserQuestion cleanup error:', err);
});
}
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
});
await this.flushCliTrace(traceSession);
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
// exit as a clean shutdown — surfacing it as an error would make a
// user-initiated cancel look like an agent failure, and an Electron
// shutdown affecting OTHER running CC sessions would pollute their
// topics with a misleading "Agent exited with code 143" message.
if (session.cancelledByUs) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
return;
}
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
const sessionError = this.getSessionErrorPayload(errorMsg, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
);
}
});
});
});
}
@@ -1049,10 +1127,54 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
/**
* Cleanup on app quit.
* Renderer main: deliver the user's answer to a pending CC AskUserQuestion
* (or signal cancellation). The matching bridge resolves its blocked
* `pending()` Promise, the local MCP handler returns to CC, and CC's
* `tool_result` flows back through the normal stream pipeline.
*
* Idempotent late submissions for already-resolved tool calls are no-ops.
* No-op when called for an unknown opId; the bridge may have been cleaned
* up already (op finished / cancelled).
*/
@IpcMethod()
async submitIntervention(params: SubmitInterventionParams): Promise<void> {
const slot = this.opIdToIntervention.get(params.operationId);
if (!slot) {
logger.warn('submitIntervention: no active intervention for operationId', params.operationId);
return;
}
slot.bridge.resolve(params.toolCallId, {
cancelReason: params.cancelled ? (params.cancelReason ?? 'user_cancelled') : undefined,
cancelled: params.cancelled,
result: params.result,
});
}
/**
* Synchronously unlink every pending intervention's temp `mcp.json`. The
* async exit-handler cleanup loses to Electron's main-process teardown
* often enough that we'd leak `lobe-cc-mcp-<opId>.json` files into
* `os.tmpdir()` on real shutdowns; sync unlink here is the only reliable
* guarantee. Safe to call multiple times.
*/
private unlinkPendingInterventionConfigsSync = (): void => {
for (const [, intervention] of this.opIdToIntervention) {
try {
unlinkSync(intervention.tmpConfigPath);
} catch {
/* file may already be gone — fine */
}
}
};
/**
* Cleanup on app quit. `before-quit` covers the user-driven Cmd+Q /
* `app.quit()` path; SIGTERM / SIGINT cover external kills (test
* harnesses, OS shutdown) where Electron's lifecycle events never fire.
*/
afterAppReady() {
electronApp.on('before-quit', () => {
this.unlinkPendingInterventionConfigsSync();
for (const [, session] of this.sessions) {
if (session.process && !session.process.killed) {
session.cancelledByUs = true;
@@ -1060,6 +1182,28 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
}
this.sessions.clear();
// The exit handlers will tear each per-op intervention down, but if
// CC's stdio close races shutdown we'd leave the MCP server bound to
// a port. Stopping it here cancels every still-pending bridge with
// `session_ended` and closes the listener.
void this.askUserMcpServer?.stop().catch((err) => {
logger.warn('AskUserQuestion MCP server stop error:', err);
});
});
const onSignal = (signal: NodeJS.Signals) => {
this.unlinkPendingInterventionConfigsSync();
// Defer to Electron's normal quit flow so the rest of the app gets a
// chance to tear down. The `before-quit` handler above is idempotent.
try {
electronApp.quit();
} catch {
/* during late shutdown app.quit may throw — fine */
}
// Last-resort exit if Electron is wedged and won't quit on its own.
setTimeout(() => process.exit(signal === 'SIGINT' ? 130 : 143), 1000).unref();
};
process.on('SIGTERM', onSignal);
process.on('SIGINT', onSignal);
}
}
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
@@ -24,6 +24,9 @@ import {
type PickFileResult,
type PrepareSkillDirectoryParams,
type PrepareSkillDirectoryResult,
type ProjectFileIndexEntry,
type ProjectFileIndexParams,
type ProjectFileIndexResult,
type RenameLocalFileResult,
type ResolveSkillResourcePathParams,
type ResolveSkillResourcePathResult,
@@ -35,6 +38,7 @@ import {
} from '@lobechat/electron-client-ipc';
import {
editLocalFile,
expandTilde,
listLocalFiles,
moveLocalFiles,
readLocalFile,
@@ -42,6 +46,7 @@ import {
writeLocalFile,
} from '@lobechat/local-file-shell';
import { dialog, shell } from 'electron';
import { execa } from 'execa';
import { unzipSync } from 'fflate';
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
@@ -81,6 +86,50 @@ const resolveNearestExistingRealPath = async (targetPath: string): Promise<strin
}
};
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const createProjectFileEntry = (
root: string,
absolutePath: string,
isDirectory: boolean,
): ProjectFileIndexEntry => {
const relativePath = toPosixRelativePath(path.relative(root, absolutePath));
return {
isDirectory,
name: path.basename(absolutePath),
path: absolutePath,
relativePath: isDirectory ? `${relativePath}/` : relativePath,
};
};
const collectProjectDirectories = (files: string[], root: string): ProjectFileIndexEntry[] => {
const directories = new Set<string>();
for (const filePath of files) {
let current = path.dirname(filePath);
while (current && current !== root && current.startsWith(`${root}${path.sep}`)) {
if (directories.has(current)) break;
directories.add(current);
current = path.dirname(current);
}
}
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
};
const createDetectedProjectFileEntry = async (
root: string,
absolutePath: string,
): Promise<ProjectFileIndexEntry> => {
try {
const stats = await stat(absolutePath);
return createProjectFileEntry(root, absolutePath, stats.isDirectory());
} catch {
return createProjectFileEntry(root, absolutePath, false);
}
};
const resolveSafePathRealPrefixes = async (): Promise<string[]> => {
const prefixes = new Set<string>(SAFE_PATH_PREFIXES);
@@ -413,14 +462,127 @@ export default class LocalFileCtr extends ControllerModule {
// ==================== Search & Find ====================
@IpcMethod()
async getProjectFileIndex(params: ProjectFileIndexParams = {}): Promise<ProjectFileIndexResult> {
const requestedScope = params.scope || process.cwd();
const startedAt = Date.now();
try {
const rootResult = await execa(
'git',
['-C', requestedScope, 'rev-parse', '--show-toplevel'],
{
reject: false,
timeout: 5000,
},
);
const root = rootResult.exitCode === 0 ? rootResult.stdout.trim() : requestedScope;
if (rootResult.exitCode === 0) {
const [trackedResult, untrackedResult] = await Promise.all([
execa(
'git',
['-C', root, '-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
{
reject: false,
timeout: 10_000,
},
),
execa(
'git',
[
'-C',
root,
'-c',
'core.quotepath=false',
'ls-files',
'--others',
'--exclude-standard',
],
{ reject: false, timeout: 10_000 },
),
]);
if (trackedResult.exitCode !== 0) {
throw new Error(trackedResult.stderr || 'git ls-files failed');
}
const files = [
...trackedResult.stdout.split('\n'),
...(untrackedResult.exitCode === 0 ? untrackedResult.stdout.split('\n') : []),
]
.map((item) => item.trim())
.filter(Boolean)
.map((relativePath) => path.resolve(root, relativePath));
const seen = new Set<string>();
const fileEntries = files
.filter((filePath) => {
if (seen.has(filePath)) return false;
seen.add(filePath);
return true;
})
.map((filePath) => createProjectFileEntry(root, filePath, false));
const entries = [...collectProjectDirectories(files, root), ...fileEntries];
logger.debug('Project file index built from git', {
duration: Date.now() - startedAt,
entries: entries.length,
files: fileEntries.length,
requestedScope,
root,
});
return {
entries,
indexedAt: new Date().toISOString(),
root,
source: 'git',
totalCount: entries.length,
};
}
} catch (error) {
logger.debug('Git project file index failed, falling back to glob', {
error,
requestedScope,
});
}
const fallback = await this.searchService.glob({ pattern: '**/*', scope: requestedScope });
const files = fallback.files.map((filePath) => path.resolve(filePath));
const entries = await Promise.all(
files.map((filePath) => createDetectedProjectFileEntry(requestedScope, filePath)),
);
logger.debug('Project file index built from glob', {
duration: Date.now() - startedAt,
entries: entries.length,
engine: fallback.engine,
requestedScope,
});
return {
entries,
indexedAt: new Date().toISOString(),
root: requestedScope,
source: 'glob',
totalCount: entries.length,
};
}
/**
* Handle IPC event for local file search
*/
@IpcMethod()
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
const effectiveDirectory = expandTilde(params.directory ?? params.scope);
logger.debug('Received file search request:', {
directory: params.directory,
effectiveDirectory,
limit: params.limit,
keywords: params.keywords,
scope: params.scope,
});
// Build search options from params, mapping directory to onlyIn
@@ -436,7 +598,7 @@ export default class LocalFileCtr extends ControllerModule {
liveUpdate: params.liveUpdate,
modifiedAfter: params.modifiedAfter ? new Date(params.modifiedAfter) : undefined,
modifiedBefore: params.modifiedBefore ? new Date(params.modifiedBefore) : undefined,
onlyIn: params.directory, // Map directory param to onlyIn option
onlyIn: effectiveDirectory,
sortBy: params.sortBy,
sortDirection: params.sortDirection,
};
@@ -446,6 +608,14 @@ export default class LocalFileCtr extends ControllerModule {
logger.debug('File search completed', {
count: results.length,
directory: params.directory,
effectiveDirectory,
results: results.slice(0, 5).map((result) => ({
engine: result.engine,
isDirectory: result.isDirectory,
name: result.name,
path: result.path,
})),
scope: params.scope,
});
return results;
} catch (error) {
@@ -155,6 +155,9 @@ export default class NotificationCtr extends ControllerModule {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.browserWindow.focus();
if (params.navigate?.path) {
mainWindow.broadcast('navigate', params.navigate);
}
});
notification.on('close', () => {
@@ -19,6 +19,26 @@ export default class TrayMenuCtr extends ControllerModule {
mainWindow.toggleVisible();
}
/**
* Get whether the application tray is visible.
*/
@IpcMethod()
getAppTrayVisible(): boolean {
return this.app.storeManager.get('appTrayVisible', true);
}
/**
* Persist and apply application tray visibility.
*/
@IpcMethod()
setAppTrayVisible(visible: boolean) {
logger.debug(`Set app tray visibility: ${visible}`);
this.app.storeManager.set('appTrayVisible', visible);
this.app.trayManager.setAppTrayVisible(visible);
return { success: true };
}
/**
* Show tray balloon notification
* @param options Balloon options
@@ -433,18 +433,23 @@ describe('GatewayConnectionCtr', () => {
}
it.each([
['readLocalFile', 'readFile', mockLocalFileCtr],
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
['moveLocalFiles', 'handleMoveFiles', mockLocalFileCtr],
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
['searchLocalFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
['editLocalFile', 'handleEditFile', mockLocalFileCtr],
['globLocalFiles', 'handleGlobFiles', mockLocalFileCtr],
['readFile', 'readFile', mockLocalFileCtr],
['listFiles', 'listLocalFiles', mockLocalFileCtr],
['moveFiles', 'handleMoveFiles', mockLocalFileCtr],
['searchFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
['writeFile', 'handleWriteFile', mockLocalFileCtr],
['editFile', 'handleEditFile', mockLocalFileCtr],
['globFiles', 'handleGlobFiles', mockLocalFileCtr],
['grepContent', 'handleGrepContent', mockLocalFileCtr],
['runCommand', 'handleRunCommand', mockShellCommandCtr],
['getCommandOutput', 'handleGetCommandOutput', mockShellCommandCtr],
['killCommand', 'handleKillCommand', mockShellCommandCtr],
// Legacy aliases — older Gateway versions may still send the long form.
// `renameLocalFile` is kept even though the new surface drops rename.
['readLocalFile', 'readFile', mockLocalFileCtr],
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
] as const)('should route %s to %s', async (apiName, methodName, controller) => {
const client = await connectAndOpen();
const args = { test: 'arg' };
@@ -470,7 +475,7 @@ describe('GatewayConnectionCtr', () => {
});
const client = await connectAndOpen();
client.simulateToolCallRequest('readLocalFile', { path: '/a.txt' }, 'req-42');
client.simulateToolCallRequest('readFile', { path: '/a.txt' }, 'req-42');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
@@ -497,7 +502,7 @@ describe('GatewayConnectionCtr', () => {
vi.mocked(mockLocalFileCtr.readFile).mockRejectedValueOnce(new Error('File not found'));
const client = await connectAndOpen();
client.simulateToolCallRequest('readLocalFile', { path: '/missing' }, 'req-err');
client.simulateToolCallRequest('readFile', { path: '/missing' }, 'req-err');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({

Some files were not shown because too many files have changed in this diff Show More