Skip to content

feat(web-shell): expose Agent management in sidebar - #11140

Draft
yiliang114 wants to merge 1 commit into
codex/agent-team-roster-web-shellfrom
codex/agent-team-discovery-web-shell
Draft

feat(web-shell): expose Agent management in sidebar#11140
yiliang114 wants to merge 1 commit into
codex/agent-team-roster-web-shellfrom
codex/agent-team-discovery-web-shell

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

This stacked PR adds a first-class Agents entry to the WebShell primary sidebar. The entry opens the existing Agent definition manager instead of introducing another management surface. The manager now also explains that Qwen can coordinate specialized Agents in an Agent Team.

Why it's needed

WebShell already supports creating and managing real Agent definitions, but the manager is only discoverable through /agents or a nested Plugins tab. A visible workspace-level entry makes the capability understandable without implying durable Team Run management that the daemon does not yet provide.

Reviewer Test Plan

How to verify

Start WebShell for a workspace and confirm that Agents appears between New Task and Plugins. Select it and verify that the existing Agent manager loads, lists Agent definitions, shows the Agent Team collaboration explanation, and opens the existing creation form. Disable workspace project features or omit agents from the configured primary navigation and confirm that the entry is hidden.

Evidence (Before & After)

Before: the primary sidebar exposed New Task, Plugins, Channels, Scheduled Tasks, and Goals; Agent management required /agents or Plugins > Agents.

After: the primary sidebar exposes Agents, selecting it opens the existing manager, and Create opens the established Agent editor with Basic Information, System Prompt, Tools, MCP, and Hooks sections.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Built and bundled locally, then started with node dist/cli.js serve in an isolated tmux session and exercised through Chrome. Full WebShell App tests passed (746/746), full sidebar tests passed (119/119), and build, typecheck, bundle, Prettier, and focused ESLint passed.

Risk & Scope

  • Main risk or tradeoff: the default primary navigation gains one item; embedders can omit it through the existing navigation customization.
  • Not validated / out of scope: durable Team Run history, remote Agent hosts, Team creation, and runtime orchestration changes remain out of scope.
  • Breaking changes / migration notes: none; the new sidebar callback is optional.

Linked Issues

Depends on #11072. This PR is intentionally based on codex/agent-team-roster-web-shell; merge #11072 first, then retarget this PR to main if GitHub does not do so automatically.

中文说明

本 PR 做了什么

这个堆叠 PR 在 WebShell 一级侧栏中增加了“智能体”入口。它直接打开现有智能体定义管理器,没有再造一个管理界面。管理页还增加了一句说明,让用户明确知道 Qwen 可以通过 Agent Team 协调专业智能体。

为什么需要

WebShell 已经支持创建和管理真实的智能体定义,但此前只能通过 /agents 或“插件”中的二级标签发现。工作空间级的可见入口能让用户直接理解这项能力,同时不会暗示 Daemon 已经提供尚不存在的持久化 Team Run 管理。

Reviewer 验证计划

如何验证

为一个工作空间启动 WebShell,确认“智能体”显示在“新建任务”和“插件”之间。点击后确认现有智能体管理器能够加载和列出定义,显示 Agent Team 协作说明,并能打开现有创建表单。关闭工作空间项目能力,或从一级导航配置中省略 agents,确认该入口会隐藏。

前后对比证据

之前:一级侧栏只显示新建任务、插件、频道、定时任务和目标;智能体管理需要使用 /agents 或进入“插件 > 智能体”。

之后:一级侧栏直接显示“智能体”;点击后打开现有管理器,“创建”会进入已有的智能体编辑器,其中包含基本信息、系统提示词、工具、MCP 和 Hooks。

测试平台

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

本地完成 build 和 bundle 后,在隔离的 tmux 会话中使用 node dist/cli.js serve 启动,并通过 Chrome 操作。WebShell App 全量测试 746/746、侧栏全量测试 119/119 通过;build、typecheck、bundle、Prettier 和定向 ESLint 均通过。

风险与范围

  • 主要风险或权衡:默认一级导航增加一个入口;嵌入方仍可通过现有导航定制将其省略。
  • 未验证 / 不在范围内:持久化 Team Run 历史、远程 Agent Host、Team 创建和运行时编排逻辑。
  • 破坏性变更 / 迁移说明:无;新增侧栏回调为可选项。

关联事项

依赖 #11072。本 PR 有意基于 codex/agent-team-roster-web-shell;请先合并 #11072,之后如果 GitHub 没有自动调整 base,再将本 PR 改为 main

@yiliang114

Copy link
Copy Markdown
Collaborator Author

E2E test report

Result: PASS on macOS with the built bundle.

Baseline

Started the global Qwen Code 0.22.3-nightly.20260831.3a0c4c6108 with qwen serve in tmux and opened it in Chrome. The primary sidebar had no Agents entry; management required /agents or Plugins > Agents.

Built-branch verification

  1. Ran npm run build, npm run typecheck, and npm run bundle successfully.
  2. Started node dist/cli.js serve --port 43225 --hostname 127.0.0.1 --workspace <worktree> --web in an isolated tmux session and runtime directory.
  3. Opened the WebShell in Chrome and observed the accessible 智能体 primary-sidebar button.
  4. Activated it and verified that the existing manager loaded seven real Agent definitions and displayed 创建专业智能体,并让 Qwen 通过 Agent Team 协调它们共同完成任务。
  5. Activated 创建 and verified the existing editor sections: Basic Information, System Prompt, Tools, MCP, and Hooks.

Automated regression coverage

  • WebShell App tests: 746/746 passed.
  • WebShell sidebar tests: 119/119 passed.
  • Focused post-install navigation checks passed.
  • Prettier, focused ESLint, build, typecheck, and bundle passed.

yiliang114 added a commit that referenced this pull request Sep 7, 2026
registerMeshRoutes is mounted beside the goals surface, so the daemon
actually answers what the pages ask for.

ThreadsRoute owns the loading. Its one non-obvious rule is that every preview
response is stamped with the draft it answered and discarded if the draft has
moved on: the whole value of previewing routing is that it describes the post
about to be sent, so a slow answer to an older draft overwriting the current
one would make it lie at exactly the moment someone relies on it. The preview
is debounced on a settled draft rather than fired per keypress.

An unreachable daemon says what failed and what to do about it rather than
rendering an empty list that looks like a workspace with no threads.

Still not mounted into App.tsx's panel switch: the way to open the page is
the absorbed sidebar entry from #11140, and adding a second one here would
collide with it.
yiliang114 added a commit that referenced this pull request Sep 7, 2026
* feat(mesh): add per-turn run binding and the turn prompt envelope

Step 5a. The ambient binding is an AsyncLocalStorage frame established per
turn, not per lifetime: a mesh body works many threads in sequence, so a
frame wrapped around the launch would pin every later turn to the first
thread. Nesting a different run throws rather than shadowing, because that
can only mean the frame was established at the wrong level.

The prompt envelope restates thread identity, title, body, status and a
bounded recent window on every turn, because auto-compaction or a
transcript-backed cold revive may have removed the previous frame. A delta
is additional context after the agent's committed watermark, never the sole
context; retention loss and a replayed delivery are labelled rather than
left for the model to infer. Post text is indented past column zero so
author-controlled content cannot forge a section header — that bounds
structure spoofing only, not the instructions inside a post (§9.1).

resolveTargets' third parameter becomes required: defaulting it to
message.mentions.length > 0 re-encoded the unknown-mention fallback that the
admission foundation fixed, since an unknown @name resolves to no id yet
must still suppress the assignee.

* feat(mesh): derive thread status from every run's close obligation

Several agents work one thread. If each stamped the status when its own run
ended, the last to finish would decide: an agent reviewing its part would
hide another still working, and a blocker raised by one would be erased by
another's clean exit. No run writes the status now. Each leaves a durable
close obligation and the status is recomputed from the ones outstanding, so
no ordering of concurrent completions can leave a stale state behind.

Three rules exist because a review found each missing, and each failure left
a thread nobody could clear. A same-thread wait is discharged by any later
close, so 'A waits for B, B reviews without @-ing A' reports in_review rather
than blocked. Any later successful booking discharges an earlier failure or
unclosed return, so one launch failure no longer pins the thread after
another agent did the work. A quiescent thread whose last admission booked
nothing becomes blocked instead of sitting in in_progress with no live run
and no explanation.

Whether a human reply should discharge a blocker raised by an agent it did
not address is 9.11 and stays open; until it is decided the selector
discharges everything, and narrowing it is a change to that predicate rather
than to its callers.

* feat(mesh): record run closes and derive the thread state from them

Closing is two writes because a closing tool is called mid-turn and cannot
mark its own still-executing runtime finished. The tool records what the run
is closing as and moves it to finishing, which ends the turn; the runtime
callback records the terminal state, and only there is the thread's status
recomputed. A crash between the two leaves a finishing run with a closeKind,
which is a complete instruction for restart reconciliation — a status written
before the runtime actually stopped would be a lie the next reader cannot
detect.

A wait is refused when nothing could wake it, and the dependency is walked
over parentThreadId rather than rootThreadId so a sibling sub-thread does not
count as this thread's delegation. Any close discharges peers' waits on the
same thread. A clean exit that never called a closing tool is recorded as
unclosed rather than as implicit success.

finishRun now delegates here so a run has exactly one way to end, and
postMessage discharges outstanding obligations when it books work before
applying the aggregate status. Without those two producers the resolver's
rules had no writer: an obsolete failure kept the thread blocked after later
work succeeded, and a post that booked nothing left it in in_progress with no
live run and no explanation.

* refactor(mesh): carry system-trigger provenance on posts, drop dead exports

A structured assignment or parent report is system-authored but must keep the
run or human action that caused it, so it is charged as unattended work
without being suppressed as an ordinary self-authored post. PostMessageInput
now carries authorKind, sourceRunId and triggerKind; all three are derived by
the server and none is accepted from a model.

Removes three exports from run-lifecycle that had no reader: the booking
acknowledger duplicated what postMessage already calls directly, and the live
run listing and re-exported author id were never read.

* feat(mesh): add the six thread tools, trusting only ambient identity

No mutating tool accepts a thread, author, run, or idempotency id from the
model. A mesh agent is one long-lived body working many threads in sequence,
so an id in a tool argument is a value the model reconstructs from memory
that may have been compacted, or copied from another thread's frame. Multica
hit the same class of bug with resumed sessions carrying a previous turn's
parent id and fixed it server-side rather than trusting the argument. Here
identity comes from the ambient run frame, and every mutating call re-reads
the store to confirm that frame still names a running run of that agent on
that thread — the frame says what the dispatcher intended, the store says
what is still true, and they diverge after a cancellation or a replay.

thread_read is the one tool that takes an id, because it only reads; what it
returns is still other participants' text rather than instructions.

thread_create builds the sub-thread and its assignment trigger in one
transaction. Two would leave a crash window in which an assigned sub-thread
exists with nothing scheduled to work it. The trigger goes through ordinary
admission, so assigning cannot bypass budgets, the queue limit, or the
outcome model.

* feat(mesh): add the minimal dispatcher that turns booked work into runs

Admission decides whether a run exists; this decides when it starts and on
which body. The split matters because a booked run is a durable fact that
stays true until something changes it under the lock, while 'this agent is
free right now' expires the moment it is read. So capacity backpressure and a
runtime that reports the agent busy both leave the run queued with its
attempt unspent, and the next pass re-reads them rather than persisting a
decision that was already stale when written.

Selection is by the lock-issued queueSequence, never queuedAt and never file
order: posts arrive from processes whose wall clocks can disagree, and
directory order would starve one thread behind another purely because of how
its id sorts. The four runtime entry points stay four operations rather than
one with a flag, because a resident continuation reuses the live chat, a
resume restarts a paused entry, a revive rebuilds from the transcript, and a
launch builds the persona from scratch.

A failed or unavailable start is terminal with a typed failureStage and
releases the queue slot; leaving it queued would make one broken agent
definition look like an agent that is merely slow.

Parent reports drain with the event id as the idempotency key. Outbox
reconciliation now takes a filter so an event kind whose consumer does not
exist yet stays visibly pending instead of being acknowledged into silence.

* feat(mesh): bind the dispatcher to the background-agent runtime

Writing this adapter corrected the dispatcher's idle path from four branches
to three. Whether a completed body still has a resident runtime is not a
choice the dispatcher can make: only the registry knows, and it already
reports its own fallback as a typed outcome. A dispatcher picking between
'continue resident' and 'cold revive' would be guessing at state it cannot
see, and would cold-revive a body that was still live. The three entry points
it does choose between are genuinely distinct — build the persona, restart a
paused entry the revive path would reject, or continue a completed one.

Capacity is reported before any mutation, so a saturated registry costs a run
nothing; an entry that changed state under the adapter is left for the next
pass rather than forced; and a thrown runtime error becomes a typed failure
instead of a start nobody performed.

This is the single place that knows how a Qwen Code body is started, which is
also where a non-local runtime would be substituted.

* docs(plans): settle the Web Shell design direction before step 9 starts

Thread status here is an aggregate over outstanding obligations, not a state
somebody set. The obvious UI — a chat log with a status badge — would hide
the one thing this system knows that Slack or Linear do not: who owes what,
and what the thread is waiting on. So the thread view is a ledger of
obligations with the conversation as evidence underneath it: the header is
the resolver's own reason sentence, each agent that worked the thread gets a
lane, and system triggers read as ledger entries rather than as someone
talking.

Inherits Web Shell's existing tokens and adds no colour or typeface. A
downloaded display face would cost startup, break offline use, and clash with
every neighbouring panel in a local-first tool. Monospace appears only where
characters must align in a column or be copied exactly.

blocked and in_review deliberately share one attention treatment. They are
opposite in valence but they are the same query for the reader — this is
waiting on me — and two colours would split that scan in two. They are told
apart by the sentence, not the hue.

* docs(plans): ground the Web Shell design in Multica's shipped UI

Read against multica@7a438bd5b rather than imagined. Four of their decisions
are adopted outright: runs live in a side panel with active pinned and past
collapsed, and the row carries no availability dot because the run's status
is the story; the live working signal sits in the header, not a body card
that competes with content and scrolls away; a list beats a minimap rail for
finding; and the composer previews routing before sending, lit for
will-trigger and dimmed for suppressed, with unknown mentions named rather
than failing silently after send. That last one transfers exactly because
decideDispatch is pure — the composer can run the real rules on a draft.

What Multica cannot give us is the aggregate. Its issue status is set by a
person and its runs are per-(agent, issue), so it never has to answer 'one
agent submitted a summary while another is still working'. Ours does, and
three round-two defects were threads stuck in a state nobody could explain.
So the design budget goes there: the header is the resolver's own reason
sentence, and every run row carries its close obligation where Multica
carries a task status.

Also borrowed: a label must not assert a cause the reason code does not
carry. They keep runtime_offline, agent_runtime_required and runtime_unusable
apart because the fix differs, and conflating them sent people to reconnect a
machine that was already connected. Our nine skip reasons get the same
treatment, each naming its own fix, shared between the composer preview and
the post-send result.

* fix(mesh): stop teaching the internal codename to the model

'mesh' is this subsystem's module name, inherited from the branch it landed
on. It had leaked into two places an agent actually reads: the run envelope's
header and the shell refusal reasons. The design's own naming rule is that
users and models see agents and threads; the codename stays in file paths.

The envelope header now says what the block is for — this part is
authenticated by the runtime, the rest is not — rather than naming a
subsystem the reader has no way to know about.

* feat(mesh): serve the threads surface and build its two pages

Three properties live on the server because a browser cannot hold them. The
thread's state is resolved on read and travels with the sentence explaining
it, so the client never derives a second status word that would drift from
the resolver and win by being the one on screen. The composer's routing
preview runs the real parseMentions and decideDispatch — the same pure
functions admission runs — and writes nothing, so looking at the consequences
of a reply does not cost what accepting them costs. A post's author comes
from the authenticated surface; there is no body field that can claim to be
an agent.

The UI follows Multica's shipped shape where it earned it: runs in a side
panel with live pinned and past collapsed behind a count, the live signal in
the header rather than a body card that scrolls away, and the routing preview
before sending rather than a surprise after. Where Multica has nothing to
copy is the aggregate — its issue status is set by a person — so that is
where this design spends: the header is the resolver's own sentence and every
run row carries its close obligation.

The thread list groups by what each thread needs rather than by recency,
which buries the two that need a person under twenty that do not. blocked and
in_review share one attention treatment because they are the same reader
query; the sentence tells them apart. Each refusal names its own fix, and
reasons whose fixes differ are never merged into one label.

* feat(mesh): bind each turn to its thread, and register the thread tools

A shared-thread body works many threads in sequence, so the turn seam has to
be told which one this turn is for. AgentMeta carries the binding; the
dispatcher rewrites it immediately before each non-launch start, and on the
launch path it travels with the launch options into the first record, because
a turn that began before a patch would have run with no frame at all. Both
real seams re-read it once, synchronously, and open the AsyncLocalStorage
frame around the turn body. Re-reading per turn is the whole point: a frame
opened around the lifetime would pin the body to whichever thread it started
on, and a process-global variable would leak across concurrent turns.

The six thread tools are registered process-wide so there is one place that
knows a tool exists, and hidden from every agent not on a thread. They
already refuse without a frame, but a refusal the model can only discover by
calling costs a turn and teaches a schema that lies.

* feat(mesh): serve the threads routes and load them from one container

registerMeshRoutes is mounted beside the goals surface, so the daemon
actually answers what the pages ask for.

ThreadsRoute owns the loading. Its one non-obvious rule is that every preview
response is stamped with the draft it answered and discarded if the draft has
moved on: the whole value of previewing routing is that it describes the post
about to be sent, so a slow answer to an older draft overwriting the current
one would make it lie at exactly the moment someone relies on it. The preview
is debounced on a settled draft rather than fired per keypress.

An unreachable daemon says what failed and what to do about it rather than
rendering an empty list that looks like a workspace with no threads.

Still not mounted into App.tsx's panel switch: the way to open the page is
the absorbed sidebar entry from #11140, and adding a second one here would
collide with it.

* Revert "feat(mesh): serve the threads routes and load them from one container"

This reverts commit ff54de8.

* Revert "feat(mesh): bind each turn to its thread, and register the thread tools"

This reverts commit c608c77.

* Revert "feat(mesh): serve the threads surface and build its two pages"

This reverts commit d95f411.

* fix(mesh): claim runs before agent startup
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant