* 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.
|
||
|---|---|---|
| .. | ||
| .storybook | ||
| docs | ||
| examples | ||
| scripts | ||
| src | ||
| .npmignore | ||
| package.json | ||
| postcss.config.cjs | ||
| README.md | ||
| tailwind.config.cjs | ||
| tailwind.preset.cjs | ||
| tsconfig.json | ||
| vite.config.ts | ||
@qwen-code/webui
A shared React component library for Qwen Code applications, providing cross-platform UI components with consistent styling and behavior.
Features
- Cross-platform support: Components work seamlessly across VS Code extension, web, and other platforms
- Platform Context: Abstraction layer for platform-specific capabilities
- Tailwind CSS: Shared styling preset for consistent design
- TypeScript: Full type definitions for all components
- Storybook: Interactive component documentation and development
- Multiple Build Formats: Supports ESM, CJS, and UMD formats for different environments
- CDN Usage: Can be loaded directly in browsers via CDN
Installation
npm install @qwen-code/webui
CDN Usage
You can also use this library directly in the browser via CDN:
Option 1: With JSX Support (using Babel)
<!DOCTYPE html>
<html>
<head>
<!-- Load React -->
<script
crossorigin
src="https://unpkg.com/react@18/umd/react.production.min.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
></script>
<!-- Load Babel Standalone for JSX processing -->
<script src="https://unpkg.com/@babel/standalone@7.23.6/babel.min.js"></script>
<!-- Manually create the jsxRuntime object to satisfy the dependency -->
<script>
// Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
const withKey = (props, key) =>
key == null ? props : Object.assign({}, props, { key });
const jsx = (type, props, key) =>
React.createElement(type, withKey(props, key));
const jsxRuntime = {
Fragment: React.Fragment,
jsx,
jsxs: jsx,
jsxDEV: jsx,
};
window.ReactJSXRuntime = jsxRuntime;
window['react/jsx-runtime'] = jsxRuntime;
window['react/jsx-dev-runtime'] = jsxRuntime;
</script>
<!-- Load the webui library -->
<script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/index.umd.js"></script>
<!-- Load the CSS -->
<link
rel="stylesheet"
href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/styles.css"
/>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
// Access components from the global QwenCodeWebUI object
const { ChatViewer } = QwenCodeWebUI;
// Use the components with JSX support
const App = () => (
<ChatViewer messages={/* your messages */} />
);
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>
Option 2: Without JSX (using React.createElement directly)
<!DOCTYPE html>
<html>
<head>
<!-- Load React -->
<script
crossorigin
src="https://unpkg.com/react@18/umd/react.production.min.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
></script>
<!-- Manually create the jsxRuntime object to satisfy the dependency -->
<script>
// Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
const withKey = (props, key) =>
key == null ? props : Object.assign({}, props, { key });
const jsx = (type, props, key) =>
React.createElement(type, withKey(props, key));
const jsxRuntime = {
Fragment: React.Fragment,
jsx,
jsxs: jsx,
jsxDEV: jsx,
};
window.ReactJSXRuntime = jsxRuntime;
window['react/jsx-runtime'] = jsxRuntime;
window['react/jsx-dev-runtime'] = jsxRuntime;
</script>
<!-- Load the webui library -->
<script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/index.umd.js"></script>
<!-- Load the CSS -->
<link
rel="stylesheet"
href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/styles.css"
/>
</head>
<body>
<div id="root"></div>
<script>
// Access components from the global QwenCodeWebUI object
const { ChatViewer } = QwenCodeWebUI;
// Use the components with React.createElement (no JSX)
const App = React.createElement(ChatViewer, {
messages: [
/* your messages */
],
});
ReactDOM.render(App, document.getElementById('root'));
</script>
</body>
</html>
For a complete working example, see examples/cdn-usage-demo.html.
Quick Start
import { Button, Input, Tooltip } from '@qwen-code/webui';
import { PlatformProvider } from '@qwen-code/webui/context';
function App() {
return (
<PlatformProvider value={platformContext}>
<Button variant="primary" onClick={handleClick}>
Click me
</Button>
</PlatformProvider>
);
}
Daemon React SDK (@qwen-code/webui/daemon-react-sdk)
All daemon-related React bindings (Providers, hooks, types) are published under the daemon-react-sdk sub-path. The main entry (@qwen-code/webui) is purely UI components with zero daemon dependency.
import {
DaemonSessionProvider,
DaemonWorkspaceProvider,
useTranscriptBlocks,
useConnection,
useActions,
useStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';
Architecture
Two providers, split by lifecycle axis:
DaemonSessionProvider— per-conversation: SSE connection, transcript store, prompt/cancel/model/approval-mode/permission actions.DaemonWorkspaceProvider— per-workspace (outlives sessions): MCP, skills, tools, memory, agents, files.
<DaemonWorkspaceProvider> ← owns DaemonClient + capabilities
useMcp / useAgents / useMemory / useTools / ...
├── <DaemonSessionProvider> ← owns session + SSE + transcript store
│ useTranscriptBlocks / useActions / useConnection / useStreamingState / ...
│ ├── <ChatPanel />
│ └── <TerminalPanel />
Basic usage
import {
DaemonSessionProvider,
DaemonWorkspaceProvider,
useTranscriptBlocks,
useActions,
useConnection,
} from '@qwen-code/webui/daemon-react-sdk';
function App() {
return (
<DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170" token={token}>
<DaemonSessionProvider autoReconnect>
<ChatView />
</DaemonSessionProvider>
</DaemonWorkspaceProvider>
);
}
function ChatView() {
const blocks = useTranscriptBlocks();
const { sendPrompt, cancel } = useActions();
const { status, sessionId, currentModel } = useConnection();
// render blocks, handle input...
}
Dual-mode usage (chat + terminal share one session)
Wrap both views with a single <DaemonSessionProvider>. Both panels share one SSE connection and one transcript store.
<DaemonWorkspaceProvider baseUrl={baseUrl} token={token}>
<DaemonSessionProvider autoReconnect>
<ChatPanel />
<TerminalPanel />
</DaemonSessionProvider>
</DaemonWorkspaceProvider>
Do NOT nest multiple <DaemonSessionProvider> for the same session — that creates two SSE connections and potential state divergence.
Session hooks
| Hook | Returns |
|---|---|
useTranscriptBlocks() |
readonly DaemonTranscriptBlock[] (raw blocks) |
useTranscriptState() |
Full DaemonTranscriptState (blocks + metadata) |
useActions() |
{ sendPrompt, cancel, setModel, setApprovalMode, respondToPermission, loadSession, newSession, ... } |
useConnection() |
{ status, sessionId, currentModel, currentMode, commands, skills, models, tokenCount, tokenUsage, contextWindow } |
useStreamingState() |
'idle' | 'waiting' | 'responding' | 'thinking' |
usePromptStatus() |
'idle' | 'waiting' | 'streaming' |
usePendingPermissions() |
Unresolved permission blocks |
useActiveTodoList() |
Latest todo list, only when it still has active items |
Workspace hooks
Require an ancestor <DaemonWorkspaceProvider>:
| Hook | Description |
|---|---|
useMcp(options?) |
MCP server list + restart + tools |
useSkills(options?) |
Available skills (read-only) |
useTools(options?) |
Workspace tools + enable/disable |
useMemory(options?) |
Memory files + read/write |
useAgents(options?) |
Agent CRUD |
useSessions(options?) |
Session list (switch/new/release require nested DaemonSessionProvider) |
useFiles() |
File operations: glob, read, write, edit, stat |
useGlob() |
globWorkspace(pattern, opts) |
useWorkspace() |
Full workspace context value |
useWorkspaceActions() |
All workspace-level actions |
All resource hooks accept { autoLoad?: boolean, enabled?: boolean } and return { data, loading, error, reload }. When nested under an active DaemonSessionProvider, resource hooks also refresh from daemon workspace events that are already broadcast on the session stream (memory_changed, agent_changed, tool_toggled, MCP restart events, and workspace init events). Without an active session, hooks remain pull-based.
Props
DaemonSessionProviderProps:
| Prop | Type | Default | Description |
|---|---|---|---|
baseUrl |
string? |
inherited | Daemon HTTP base URL (inherited from DaemonWorkspaceProvider when nested; required in standalone mode) |
token |
string? |
inherited | Bearer token (inherited from DaemonWorkspaceProvider when nested) |
workspaceCwd |
string? |
— | Override workspace path (uses capabilities if omitted) |
initialSessionId |
string? |
— | Restore a specific session on mount |
clientId |
string? |
— | Override stable client ID (auto-generated if omitted) |
autoConnect |
boolean |
true |
Connect on mount |
autoReconnect |
boolean |
true |
Auto-reconnect on disconnect |
reconnectDelayMs |
number |
1000 |
Initial reconnect backoff |
maxReconnectDelayMs |
number |
10000 |
Max reconnect backoff |
suppressOwnUserEcho |
boolean |
true |
Suppress own user message echoes |
DaemonWorkspaceProviderProps:
| Prop | Type | Default | Description |
|---|---|---|---|
baseUrl |
string |
required | Daemon HTTP base URL |
token |
string? |
— | Bearer token |
workspaceCwd |
string? |
— | Override workspace path |
autoConnect |
boolean |
true |
Connect and fetch capabilities on mount |
Components
UI Components
Button
import { Button } from '@qwen-code/webui';
<Button variant="primary" size="md" loading={false}>
Submit
</Button>;
Props:
variant: 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'size: 'sm' | 'md' | 'lg'loading: booleanleftIcon: ReactNoderightIcon: ReactNodefullWidth: boolean
Input
import { Input } from '@qwen-code/webui';
<Input
label="Email"
placeholder="Enter email"
error={hasError}
errorMessage="Invalid email"
/>;
Props:
size: 'sm' | 'md' | 'lg'error: booleanerrorMessage: stringlabel: stringhelperText: stringleftElement: ReactNoderightElement: ReactNode
Tooltip
import { Tooltip } from '@qwen-code/webui';
<Tooltip content="Helpful tip">
<span>Hover me</span>
</Tooltip>;
Icons
import { FileIcon, FolderIcon, CheckIcon } from '@qwen-code/webui/icons';
<FileIcon size={16} className="text-gray-500" />;
Available icon categories:
- FileIcons: FileIcon, FolderIcon, SaveDocumentIcon
- StatusIcons: CheckIcon, ErrorIcon, WarningIcon, LoadingIcon
- NavigationIcons: ArrowLeftIcon, ArrowRightIcon, ChevronIcon
- EditIcons: EditIcon, DeleteIcon, CopyIcon
- SpecialIcons: SendIcon, StopIcon, CloseIcon
Layout Components
Container: Main layout wrapperHeader: Application headerFooter: Application footerSidebar: Side navigationMain: Main content area
Message Components
Message: Chat message displayMessageList: List of messagesMessageInput: Message input fieldWaitingMessage: Loading/waiting stateInterruptedMessage: Interrupted state display
Platform Context
The Platform Context provides an abstraction layer for platform-specific capabilities:
import { PlatformProvider, usePlatform } from '@qwen-code/webui/context';
const platformContext = {
postMessage: (message) => vscode.postMessage(message),
onMessage: (handler) => {
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
},
openFile: (path) => {
/* platform-specific */
},
platform: 'vscode',
};
function App() {
return (
<PlatformProvider value={platformContext}>
<YourApp />
</PlatformProvider>
);
}
function Component() {
const { postMessage, platform } = usePlatform();
// Use platform capabilities
}
Tailwind Preset
Use the shared Tailwind preset for consistent styling:
// tailwind.config.js
module.exports = {
presets: [require('@qwen-code/webui/tailwind.preset.cjs')],
// your customizations
};
Development
Running Storybook
cd packages/webui
npm run storybook
Building
npm run build
Type Checking
npm run typecheck
Project Structure
packages/webui/
├── src/
│ ├── components/
│ │ ├── icons/ # Icon components
│ │ ├── layout/ # Layout components
│ │ ├── messages/ # Message components
│ │ └── ui/ # UI primitives
│ ├── context/ # Platform context
│ ├── hooks/ # Custom hooks
│ └── types/ # Type definitions
├── .storybook/ # Storybook config
├── tailwind.preset.cjs # Shared Tailwind preset
└── vite.config.ts # Build configuration
License
Apache-2.0