qwen-code/packages/web-shell
Shaojin Wen ac2f371c44
feat(scheduled-tasks): add isolated run mode via create_sub_session tool (#6535)
* feat(scheduled-tasks): add isolated run mode via create_sub_session tool

Introduce a new `create_sub_session` tool (daemon-only) that spawns a
fresh top-level sub-session with its own clean context and transcript.
Wire it into the cron scheduler as an `isolated` run mode so each
scheduled fire dispatches its prompt into a fresh sub-session instead
of accumulating in one shared transcript.

- Add `create_sub_session` tool with `first-turn` and `sent` completion modes
- Add `SubSessionLauncher` in cli/serve with concurrency cap, timeout, and truncation
- Extend ACP bridge `extMethod` dispatch for child→daemon sub-session requests
- Add `runMode` field (`shared`|`isolated`) to DurableCronTask, CronJob, and API types
- Add run-mode radio picker to ScheduledTasksDialog UI
- Fix AuthMessage hardcoded placeholder to use i18n key

* fix(scheduled-tasks): dispatch isolated fires daemon-side, not via the model

An `isolated` fire was relayed through the model: the fired prompt was
wrapped with an instruction to call `create_sub_session`. That tool's
default permission is `'ask'`, so under `ApprovalMode.DEFAULT` an
unattended fire reached `client.requestPermission`, found no SSE
subscriber, and was cancelled by the daemon's 5-minute permission
timeout. The task never ran, and the cancel was booked as a successful
run — the headline use case of a scheduled task was broken.

Route isolated fires straight to the daemon instead: the cron `onFire`
handler in `Session` calls the sub-session spawner directly, with no
model relay and no tool-permission gate. The prompt was already approved
when the task was created; laundering it back through the model only
re-opened that gate. `create_sub_session` keeps `'ask'` for
model-initiated calls, and the attended "Run now" button keeps its relay
(a user is present to answer the prompt).

Also fix orphan-session cleanup in the launcher. `closeSession` was
guarded only by `.catch()`, which covers an async rejection but not a
synchronous throw; because the call sits inside the launcher's own
`catch (err)` block, a sync throw escaped and replaced the real launch
error. Guard both shapes.

Tests:
- Cover isolated routing: dispatch, in-session fallback with no spawner,
  missed one-shot, dispatch failure (dropped, never run inline), and
  shared mode.
- Cover the orphan close, including a `closeSession` that throws.
- Replace the sent-mode concurrency test, which only asserted the slot
  was eventually released (moving the release to the drain's *start*
  kept it green) with one that asserts the slot is HELD while the drain
  runs, plus one that asserts it is released at `turn_complete`.

* fix(scheduled-tasks): honor the caller's AbortSignal and harden the spawn boundary

Four findings from review, all in the model-initiated `create_sub_session`
path (the scheduled `isolated` dispatch reaches none of them).

`execute()` took no parameters, so it silently dropped the parent turn's
`AbortSignal`. `Session.ts` awaits `invocation.execute(signal)` without
racing the abort itself, so cancelling a turn with a `first-turn`
sub-session in flight pinned the caller's tool loop until the daemon's
5-minute ceiling. Accept the signal and return as soon as it fires.

The sub-session is deliberately NOT cancelled and deliberately KEEPS its
concurrency slot: `sendPrompt` has no abort seam, so the sub-session runs
on. Releasing its slot on cancel — as the review suggested — would let the
caller over-admit against sub-sessions that are still consuming a bridge
session and model quota.

`handleCreateSubSession` trusted the child-supplied `callerSessionId`
verbatim, and that id keys the launcher's per-caller concurrency bucket: a
fabricated id starts a fresh bucket at zero (cap evasion) and a victim's id
burns their slots (DoS). Validate it with the connection's existing
`ownsSession` seam.

Every daemon session wires a spawner, sub-sessions included, and each gets
its own cap-sized bucket — so one prompt could fan out 5ⁿ sub-sessions until
`maxSessions` ran dry. Gate nesting at one level: the launcher remembers the
sessions it spawned and refuses to spawn from them. With `callerSessionId`
now authenticated, the gate cannot be sidestepped.

Cap the prompt at 100,000 chars (matching the scheduled-task REST route) and
the display name at 200, both at the bridge trust boundary and, for the
prompt, in the tool's own validation so the model gets an actionable error.

Not changed: `create_sub_session` stays in `PermissionManager.CORE_TOOLS`.
Membership there SUBJECTS a tool to the `coreTools` allowlist; it does not
exempt it. Removing it — as the review suggested — is what would let the
tool bypass a user's allowlist, the way `agent` and `send_message` do today.

* fix(core): do not spawn a sub-session for an already-cancelled turn

`raceCancellation(spawner({…}), signal)` evaluated the spawner as an
argument, so the spawn started before the abort was ever checked. A turn
cancelled before `execute()` ran still created a sub-session on the daemon
— and it kept a concurrency slot — while the tool reported itself
cancelled.

Take a thunk instead, so the pre-abort check happens before any daemon work
is started. Track whether the spawn actually began, and say so: "cancelled
before it started, no sub-session was created" is a different fact from
"a sub-session may already have been created and is not cancelled".

Regression test asserts the spawner is never called for a pre-aborted
signal; it fails against the eager-argument form.

* fix(serve): require callerSessionId and stop misreporting an early stream close

Two findings from review.

`awaitFirstTurn`'s `'incomplete'` stopReason was unreachable. The cleanup
`finally` calls `ac.abort()` unconditionally to tear the subscription down,
so by the time the stopReason ternary read `ac.signal.aborted` it was always
true. An event stream that closed before the turn finished (bridge teardown,
WS drop) was reported as a 5-minute wall-clock `'timeout'` — indistinguishable
from a real one. Track the timer firing in its own flag.

`callerSessionId` was validated only when present. Omitting it handed the
launcher `undefined`, which minted an `anon:<uuid>` bucket — a fresh
concurrency bucket per call, so no cap — and skipped the depth-1 nesting gate
(`info.callerSessionId !== undefined && …`). Authenticating the id closed
forgery but not omission. It is now required at the bridge boundary, and
required in `CreateSubSessionInfo`, so the launcher's anonymous fallback and
the gate's presence check are both gone. Every real caller has a session id —
the tool only ever runs inside a session's turn.

* fix(serve): surface dropped fires and drain timeouts; bound sub-sessions per workspace

Three findings from review.

A dropped `isolated` scheduled fire left no trace. `debugLogger.warn` writes
nothing unless a debug log session is active, and the scheduler persists the
fire as a run before dispatch — so a nightly task could fail forever while its
history claimed it ran. It now also writes to stderr, which the daemon forwards
from the child.

A sent-mode drain that hit its 30-minute ceiling was equally silent: the catch
saw `drainAc.signal.aborted` and skipped logging, the `finally` freed the
concurrency slot, and the sub-session — which the abort does not cancel — kept
burning a bridge session and model quota. The timer now records its own firing
(the controller cannot: `finally` aborts it on every exit path) and the timeout
is written to stderr. The drain ceiling is injectable for tests, mirroring
`firstTurnTimeoutMs`.

The per-caller concurrency cap trusts `callerSessionId`, and the bridge can only
authenticate that id as "a session on this channel". Every session of a
workspace shares one child process, so nothing at the transport can prove which
of them issued the call — and a per-session secret would be readable by the
whole process anyway. Rather than pretend otherwise, add a workspace-wide
ceiling on concurrent sub-sessions that holds no matter which bucket a launch is
charged to.
2026-07-09 12:02:39 +00:00
..
client feat(scheduled-tasks): add isolated run mode via create_sub_session tool (#6535) 2026-07-09 12:02:39 +00:00
docs/examples/qwencode-viz feat(web-shell): support compact echarts full data blocks (#6232) 2026-07-04 15:36:54 +00:00
package.json Add harness infrastructure for web-shell package (#6517) 2026-07-09 08:11:58 +00:00
playwright.config.ts Add harness infrastructure for web-shell package (#6517) 2026-07-09 08:11:58 +00:00
README.md feat(web-shell): support compact echarts full data blocks (#6232) 2026-07-04 15:36:54 +00:00
tsconfig.build.json feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00
tsconfig.json Add harness infrastructure for web-shell package (#6517) 2026-07-09 08:11:58 +00:00
tsconfig.lib.json Add harness infrastructure for web-shell package (#6517) 2026-07-09 08:11:58 +00:00
vite.config.ts feat(web-shell): add token-usage analytics dashboard to Daemon Status (#6388) 2026-07-06 13:43:41 +00:00
vite.lib.config.ts refactor(web-shell): restructure chat UI (#5775) 2026-06-23 22:43:19 +08:00
vitest.config.ts Add harness infrastructure for web-shell package (#6517) 2026-07-09 08:11:58 +00:00

@qwen-code/web-shell

Qwen Code Web Shell 是面向浏览器的 daemon 会话终端 UI可以作为 React 组件嵌入到其他项目中。

环境要求

  • React^18.0.0 || ^19.0.0
  • React DOM^18.0.0 || ^19.0.0
  • @qwen-code/webui>=0.0.1
  • @qwen-code/sdk>=0.1.8
  • 浏览器环境需要能访问 Qwen Code daemon serve 的 HTTP 接口。

组件包会自动注入自身样式,样式已通过 CSS Modules 和组件作用域隔离; 接入方不需要额外引入全局 CSS。

安装

npm install @qwen-code/web-shell

Peer dependencies 需要同时安装:

npm install react react-dom @qwen-code/webui @qwen-code/sdk

接入方式

WebShell 提供两种接入形态:

1. 独立接入(自带 Provider

适合只需要嵌入一个终端视图的场景。组件内部自建 DaemonWorkspaceProvider + DaemonSessionProvider

import { WebShellWithProviders } from '@qwen-code/web-shell';

export function QwenCodePanel() {
  return (
    <WebShellWithProviders
      baseUrl="http://127.0.0.1:4170"
      token="your-bearer-token"
      sessionId="838e1811-9f84-4848-9915-d9a7f01ff5c6"
      onSessionIdChange={(sessionId) => {
        console.log('current session:', sessionId);
      }}
      theme="dark"
      language="zh-CN"
    />
  );
}

2. 共享 Provider 接入(纯消费者)

适合同一个 React 应用中多个视图共享同一个 daemon session 的场景(如 chat + terminal。宿主自行提供 ProviderWebShell 只消费 hooks。

import {
  DaemonWorkspaceProvider,
  DaemonSessionProvider,
} from '@qwen-code/webui/daemon-react-sdk';
import { WebShell } from '@qwen-code/web-shell';

export function App() {
  return (
    <DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170" token="...">
      <DaemonSessionProvider sessionId="...">
        <ChatPanel />
        <WebShell theme="dark" language="zh-CN" />
      </DaemonSessionProvider>
    </DaemonWorkspaceProvider>
  );
}

注意:不要在已有 DaemonSessionProvider 下使用 WebShellWithProviders,否则会创建嵌套的重复 Provider。

Props

WebShellWithProviders

包含 WebShell 的所有 Props加上 Provider 配置:

属性 类型 说明
baseUrl string daemon API 地址,未传时使用 window.location.origin
token string daemon API Bearer token
sessionId string 要连接的 session id未传或 undefined 时保持空页面

WebShell

属性 类型 说明
onSessionIdChange (sessionId: string | undefined) => void 当前 session id 变化或清空时触发
theme 'dark' | 'light' UI 主题,默认 dark
onThemeChange (theme: WebShellTheme) => void /theme 命令切换主题后触发
language 'en' | 'zh-CN' | 'zh' | 'zh-cn' UI 语言
onLanguageChange (language: WebShellLanguage) => void /language ui 切换 UI 语言后触发

可选图表 Renderer

WebShell 支持宿主通过 customization.markdown.renderCodeBlock 接管特定 fenced code block 的渲染。图表类场景可以注册内置的 echarts-fulldata renderer

import { createEchartsFullDataRenderer } from '@qwen-code/web-shell';

<WebShellWithProviders
  markdown={{
    renderCodeBlock: createEchartsFullDataRenderer({
      loadEcharts: () => window.echarts,
      resolveDataRef: async (ref, meta) =>
        loadControlledChartDataset(ref, meta),
    }),
  }}
/>;

renderer 会把 echarts-fulldata code block 替换为图表卡片,并内置图表/数据 icon 切换ECharts runtime 由宿主通过 loadEcharts 提供。若启用 data.kind="ref" envelope数据只能通过宿主提供的 resolveDataRef 解析, renderer 不会自己读取 URL 或本地路径。

如果需要让模型主动输出 echarts-fulldata block宿主应在自己的 skills 来源中 提供对应 skill并且只在确认当前 Web Shell 宿主已经注册 renderer 时启用。 @qwen-code/web-shell 不内置或自动加载这个 skill可从 packages/web-shell/docs/examples/qwencode-viz/SKILL.md 复制模板到宿主的 .qwen/skills/qwencode-viz/SKILL.md,或通过宿主自己的 skill 注入机制提供等价 说明。

echarts-fulldata 的 block body 可以是旧版纯 JSON ECharts option也可以是 { "version": 1, "data": ..., "option": ... } envelope。新版 inline envelope 使用 data.dimensions: string[]data.source array-of-arraysrenderer 会先 normalize 成原生 ECharts option并注入 option.dataset,再渲染图表和数据视图。 新版 ref envelope 必须使用受控 artifact://session-file:// ref并提供 data.formatcsvjson)和 data.dimensions,这些元信息会传给宿主的 resolveDataRef(ref, meta)。 宿主应使用 JSON.parse 解析,不能用 evalnew Function 或 script injection 执行模型生成内容。

架构说明

@qwen-code/sdk/daemon         ← 协议层SSE, REST, normalizer
@qwen-code/webui/daemon-react-sdk  ← React adapterProvider, hooks, store
@qwen-code/web-shell          ← 终端 UI 组件
  • WebShell 必须在 DaemonWorkspaceProviderDaemonSessionProvider 之下使用。
  • WebShellWithProviders 是内置 Provider 的便捷 wrapper。
  • 同一个 React 树共享一个 DaemonSessionProvider 时只开一条 SSE。

已支持的斜杠命令

下面列出当前 web-shell 已支持的命令。支持方式分为两类:

  • 本地实现web-shell 前端直接打开弹窗、调用 daemon REST API或切换本地状态。
  • ACP 透传web-shell 将命令发送给 daemon由 daemon/ACP 执行。
命令 支持方式 说明
/help 本地实现 打开帮助弹窗,支持键盘浏览命令和快捷键。
/theme 本地实现 打开主题选择弹窗;支持 /theme light/theme dark
/language 本地实现 + ACP 透传 /language ui <lang> 会切换 web-shell UI 语言并同步给 daemon其他语言能力由 daemon 执行。包含 uioutput 子命令。
/model 本地实现 + 部分透传 无参数打开模型弹窗;普通参数直接切换模型;/model --fast <model> 透传给 daemon。
/plan 本地实现 切换到 plan approval mode并可继续发送后续 prompt。
/approval-mode 本地实现 打开审批模式弹窗或直接切换审批模式。
/mode 本地实现 web-shell 本地别名,用于切换审批模式。
/mcp 本地实现 打开 MCP 管理弹窗。
/skills 本地实现 + ACP 透传 无参数打开 skills 弹窗;带参数时透传给 daemon 执行。
/tools 本地实现 打开 tools 弹窗,列表展示工具名称、启用状态和 description
/memory 本地实现 打开 memory 弹窗,支持 showrefreshadd useradd project 等分支。
/agents 本地实现 打开 agents 弹窗,支持 managecreate usercreate project 等分支。
/copy 本地实现 复制最后一条 assistant 输出;支持 code、语言名、LaTeX、inline LaTeX 等选择器。
/release 本地实现 释放 live session 连接,不删除历史会话记录。
/clear 本地实现 清空当前 web-shell transcript store。
/new 本地实现 创建新的 daemon session。
/reset 本地实现 /new 一样创建新的 daemon session。
/rename <name> 本地实现 修改当前 daemon session 的展示名称。
/resume 本地实现 无参数打开恢复会话弹窗;带 session id 时直接加载。
/status ACP 透传 daemon 支持,包含 paths 子命令。
/auth ACP 透传 连接 LLM provider。
/bug ACP 透传 提交错误报告。
/compress ACP 透传 通过摘要替换来压缩上下文。
/context ACP 透传 显示上下文窗口使用情况,包含 detail 子命令。
/diff ACP 透传 显示工作区相对 HEAD 的变更统计。
/docs ACP 透传 打开 Qwen Code 文档。
/doctor ACP 透传 执行安装与环境诊断,包含 memory 子命令。
/export ACP 透传 导出当前会话记录,包含 htmlmdjsonjsonl 子命令。
/goal ACP 透传 设置目标,并持续工作直到条件满足。
/init ACP 透传 分析项目并创建定制的 QWEN.md
/stats ACP 透传 显示统计信息,包含 modeltools 子命令。
/summary ACP 透传 生成当前会话摘要。
/tasks ACP 透传 列出后台任务。
/insight ACP 透传 查看 insight 相关信息。