feat(daemon): server-side shell command execution for ! (bang) prefix#4576
Conversation
Add direct shell command execution in daemon mode, matching CLI semantics: commands run immediately via ShellExecutionService without LLM involvement, output streams to clients via SSE, and results are injected into LLM history for context in subsequent turns. - New POST /session/:id/shell route in daemon server - Bridge executeShellCommand with streaming output via shell_output SSE events - ACP extMethod sessionShellHistory for LLM history injection - SDK client shellCommand() method and DaemonShellCommandResult type - Web-shell ! handler calls server-side execution instead of wrapping as LLM prompt - Channel adapters detect ! prefix and route through direct execution - New user_shell_command / user_shell_result SSE event types 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
📋 Review SummaryThis PR implements server-side shell command execution for the daemon mode, allowing the 🔍 General Feedback
🎯 Specific Feedback🔴 Critical
🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
There was a problem hiding this comment.
Pull request overview
This PR adds a daemon-side “direct shell execution” path for ! (bang) commands, exposed via a new POST /session/:id/shell route and surfaced across the SDK, web-shell UI, and channel adapters, with output streaming via SSE and best-effort history injection for LLM context.
Changes:
- Add daemon HTTP + bridge support to execute shell commands directly (bypassing the LLM) and stream output via SSE.
- Extend the TypeScript SDK with
shellCommand()APIs and new daemon event types (user_shell_command,user_shell_result) plus UI normalizer support. - Update web-shell and channel adapters to route
! ...through the new direct-execution path.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/web-shell/client/hooks/useDaemonSession.ts | Adds sendShellCommand() action to call the daemon session’s shell endpoint via SDK. |
| packages/web-shell/client/App.tsx | Routes ! to sendShellCommand() and removes the prior LLM-prompt wrapper helper. |
| packages/sdk-typescript/src/index.ts | Re-exports DaemonShellCommandResult. |
| packages/sdk-typescript/src/daemon/ui/normalizer.ts | Normalizes new user_shell_command / user_shell_result daemon events for UI consumption. |
| packages/sdk-typescript/src/daemon/types.ts | Introduces DaemonShellCommandResult type. |
| packages/sdk-typescript/src/daemon/index.ts | Re-exports DaemonShellCommandResult from daemon module. |
| packages/sdk-typescript/src/daemon/events.ts | Adds new known daemon event type strings. |
| packages/sdk-typescript/src/daemon/DaemonSessionClient.ts | Adds shellCommand() session method delegating to DaemonClient. |
| packages/sdk-typescript/src/daemon/DaemonClient.ts | Implements POST /session/:id/shell client call. |
| packages/cli/src/serve/server.ts | Adds POST /session/:id/shell route with disconnect abort wiring and client-id parsing. |
| packages/cli/src/acp-integration/acpAgent.ts | Adds ACP extMethod to inject shell command + output into Gemini history. |
| packages/channels/base/src/DaemonChannelBridge.ts | Exposes optional shellCommand() capability for channel session clients. |
| packages/channels/base/src/ChannelBase.ts | Detects ! prefix in inbound messages and routes to direct shell execution. |
| packages/acp-bridge/src/status.ts | Adds sessionShellHistory extMethod name constant. |
| packages/acp-bridge/src/bridgeTypes.ts | Extends bridge interface with executeShellCommand() and ShellCommandResult. |
| packages/acp-bridge/src/bridge.ts | Implements executeShellCommand() using ShellExecutionService, SSE fanout, timeout, and history injection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| }); | ||
|
|
||
| app.post('/session/:id/shell', mutate(), async (req, res) => { |
There was a problem hiding this comment.
Thanks — won't take this one. The shell route deliberately uses non-strict mutate() to match POST /session/:id/prompt, which can also execute arbitrary commands via the LLM's Bash tool. Both routes share the same security posture by design; the prompt route is already the precedent.
| const outputChunks: string[] = []; | ||
| const abort = new AbortController(); | ||
| const onSignalAbort = () => abort.abort(); | ||
| signal?.addEventListener('abort', onSignalAbort, { once: true }); | ||
|
|
There was a problem hiding this comment.
Thanks — won't take this one. The 120s SHELL_COMMAND_TIMEOUT_MS provides the bound — same pattern as the CLI's ShellExecutionService usage which also accumulates unboundedly until timeout. Capping mid-stream would silently truncate output the user explicitly asked to see.
| case 'user_shell_command': { | ||
| const command = getString(event.data, 'command'); | ||
| return command | ||
| ? [{ ...base, type: 'user.text.delta', text: `! ${command}` }] | ||
| : []; | ||
| } | ||
| case 'user_shell_result': { | ||
| const exitCode = numberField(event.data, 'exitCode'); | ||
| return [ |
There was a problem hiding this comment.
Thanks — deferring. Unit tests for the normalizer event types will be added in a follow-up PR.
Replace `'shellCommand' in this.bridge` with `typeof === 'function'` check for safer runtime capability detection. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Bot review triage summary
|
| } | ||
| }); | ||
|
|
||
| app.post('/session/:id/shell', mutate(), async (req, res) => { |
There was a problem hiding this comment.
[Critical] The PR adds ~327 lines of new code across 16 source files with 0 test files. Critical untested paths include:
- This
POST /session/:id/shellroute (validation, abort-on-disconnect, error handling) bridge.executeShellCommand(timeout, output truncation, event publishing, abort propagation)- Channel
!handler inChannelBase.ts(fallback when bridge lacksshellCommand, output formatting) sessionShellHistoryext method inacpAgent.ts(history injection)- SDK
DaemonClient.shellCommand(URL construction, error handling)
Every comparable route/method in the codebase has dedicated test coverage. Consider adding tests for at least the server route, bridge method, and channel handler.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Agreed the test coverage gap is real. Deferring to a follow-up PR — this PR is already 16 files and adding tests for bridge/server/normalizer would double the scope.
- Fix AnsiOutput serialization (AnsiToken[][] has no .text property) - Align MAX_SHELL_OUTPUT_FOR_HISTORY with CLI's 10KB limit - Add debug logging for failed history injection (was empty catch) - Emit user_shell_result on ShellExecutionService.execute() failure - Use dynamic backtick fencing in channel shell output - Forward AbortSignal through DaemonChannelBridge.shellCommand - Show "aborted" status instead of "code unknown" in normalizer 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
wenshao review triage — round 1All fixes in commit 0e83458.
|
wenshao
left a comment
There was a problem hiding this comment.
R2: All 8 R1 findings addressed in 0e83458. AnsiOutput serialization, MAX_SHELL_OUTPUT alignment, error logging, outer catch error publishing, dynamic backtick fencing, AbortSignal forwarding, and aborted-flag normalization all look correct. Build passes, 985 tests pass, deterministic analysis clean. LGTM! ✅ — qwen3.7-max via Qwen Code /review
chiga0
left a comment
There was a problem hiding this comment.
Code Review Overview (AI Generated)
PR: #4576 feat(daemon): server-side shell command execution for ! (bang) prefix
Type: New Feature
Change size: +356/-10 across 16 files
Findings Summary
- Critical/Major: 3 items
- Minor: 5 items
- Nit: 1 item
Key Observations
Well-structured feature PR that adds a clean server-side shell execution pipeline across all daemon clients (HTTP route → bridge → ShellExecutionService → SSE streaming → LLM history injection). The architecture correctly reuses ShellExecutionService and follows existing patterns. Main concerns are: (1) unbounded output buffer that could OOM on long-running commands, (2) unsafe Record<string, unknown> type cast in the channel adapter, and (3) LLM history injection template vulnerable to backtick escaping. No automated tests included despite test plan in description.
This review was generated by QoderWork AI
| line.map((t) => t.text).join(''), | ||
| ) | ||
| .join('\n'); | ||
| outputChunks.push(chunk); |
There was a problem hiding this comment.
[Major] Unbounded output buffer — potential OOM for long-running commands
outputChunks collects the full command output in memory without any size limit. While historyOutput is correctly capped at 10KB for LLM history injection, the raw output returned in ShellCommandResult has no bound. A command like cat on a multi-GB file or yes running for the full 120s timeout would consume unbounded memory before being returned.
Consider capping the buffer (e.g. keep first/last 500KB) or switching to a ring buffer, similar to how the CLI already handles shell output limits. The 10KB cap for history is good, but the full output buffer should also have a ceiling.
This review was generated by QoderWork AI
| await entry.connection.extMethod( | ||
| SERVE_CONTROL_EXT_METHODS.sessionShellHistory, | ||
| { sessionId, command, output: historyOutput, exitCode }, | ||
| ); |
There was a problem hiding this comment.
[Minor] History injection template vulnerable to backtick escaping
outputText is placed inside a triple-backtick code fence, but if the command output itself contains ```, it will break the code fence and corrupt the LLM's chat history structure. This could cause confusing LLM behavior in subsequent turns.
Apply the same dynamic backtick fencing that ChannelBase.ts already implements:
const longestRun = Math.max(0, ...Array.from(outputText.matchAll(/`+/g), m => m[0].length));
const fence = '`'.repeat(Math.max(3, longestRun + 1));
geminiClient.addHistory({
role: 'user',
parts: [{ text: `I ran the following shell command:\n${fence}sh\n${command}\n${fence}\n\nThis produced the following result:\n${fence}\n${outputText}\n${fence}` }],
});This review was generated by QoderWork AI
| }, | ||
| abort.signal, | ||
| false, | ||
| { terminalWidth: 120, terminalHeight: 40 }, |
There was a problem hiding this comment.
[Minor] Timeout starts after await ShellExecutionService.execute() resolves
The timeout setTimeout is set after the await ShellExecutionService.execute() returns the handle. If the execute call itself takes significant time (e.g. process spawning overhead), the actual command runtime exceeds the intended 120s limit by that overhead. While usually negligible, consider starting the timer before the await for more accurate timeout semantics:
const timeoutId = setTimeout(() => abort.abort(), SHELL_COMMAND_TIMEOUT_MS);
timeoutId.unref();
try {
const handle = await ShellExecutionService.execute(...);
const result = await handle.result;
} finally {
clearTimeout(timeoutId);
}This review was generated by QoderWork AI
| // 3.5. Bang (!) shell command — direct execution, no LLM | ||
| if (envelope.text.startsWith('!')) { | ||
| const cmd = envelope.text.slice(1).trim(); | ||
| const bridgeShellCommand = (this.bridge as unknown as Record<string, unknown>)['shellCommand']; |
There was a problem hiding this comment.
[Major] Unsafe Record<string, unknown> type cast — use typed interface or type guard
This cast to Record<string, unknown> followed by bracket access and a typeof check is a type-safety anti-pattern. It bypasses TypeScript's type checking entirely and is fragile to refactoring — renaming shellCommand on the bridge won't produce a compile error here.
Consider either:
- Adding a proper capability check to the bridge interface:
interface ShellCapableBridge {
shellCommand(sessionId: string, command: string): Promise<...>;
}
function hasShellCommand(bridge: unknown): bridge is ShellCapableBridge {
return typeof (bridge as ShellCapableBridge)?.shellCommand === 'function';
}- Or declaring
shellCommandas an optional method on the base bridge type and usingthis.bridge.shellCommand?.(...).
Also note: when the bridge doesn't support shellCommand, the ! command silently falls through to LLM processing (see related Minor finding on ChannelBase.ts line 278).
This review was generated by QoderWork AI
| const cmd = envelope.text.slice(1).trim(); | ||
| const bridgeShellCommand = (this.bridge as unknown as Record<string, unknown>)['shellCommand']; | ||
| if (cmd && typeof bridgeShellCommand === 'function') { | ||
| try { |
There was a problem hiding this comment.
[Minor] Silent fall-through when ! command is not supported
When ! is detected but either cmd is empty or the bridge doesn't support shellCommand, the code falls through to normal LLM processing. This means:
!alone (no command) gets sent to the LLM as a bare exclamation mark!lson a bridge withoutshellCommandcapability gets sent as raw text to the LLM
The user typed ! expecting shell execution. They should get feedback, not a confusing LLM response. Add an else branch:
if (cmd && typeof bridgeShellCommand === 'function') {
// ... existing logic
} else if (envelope.text.startsWith('!')) {
await this.sendMessage(
envelope.chatId,
cmd ? 'Shell command execution is not supported in this mode' : 'Usage: ! <command>',
);
return;
}This review was generated by QoderWork AI
chiga0
left a comment
There was a problem hiding this comment.
Continued review findings (Part 2/2)
| '\n... (truncated)' | ||
| : output; | ||
|
|
||
| try { |
There was a problem hiding this comment.
[Minor] role: 'user' for shell history injection may mislead the LLM
The shell command result is injected as a user message in the LLM's chat history. This makes it appear as if the user typed the command and output themselves, which could confuse the model about the user's intent in subsequent turns (e.g. the model might think the user is showing it a command rather than reporting an execution result).
If the Gemini API supports system or model roles for addHistory, consider using those. Alternatively, prefix with a clearer attribution like [System: Shell command executed by user via daemon] to disambiguate. Check what the CLI's addShellCommandToGeminiHistory uses for consistency.
This review was generated by QoderWork AI
| @@ -749,9 +749,9 @@ export function App({ | |||
| const cmd = text.slice(1).trim(); | |||
| if (!cmd) return false; | |||
| actions | |||
There was a problem hiding this comment.
[Nit] command.trim() removes intentional leading whitespace
The server route also applies command.trim() (server.ts line 1888), which strips leading/trailing whitespace from the user's input. For most commands this is fine, but ! echo hello becomes echo hello, and ! echo ' spaces ' would work correctly since only the outer trim applies.
This is consistent with how the CLI handles shell commands, so it may be intentional. Just noting it as a potential edge case if users expect to paste commands with intentional leading whitespace.
This review was generated by QoderWork AI
| @@ -1084,11 +1084,4 @@ export function App({ | |||
| ); | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
[Nit] Extra blank lines after removing formatShellCommandPrompt
After removing the formatShellCommandPrompt function, two blank lines remain (lines 1085-1086). Minor cleanup — one trailing blank line before the file end is the common convention.
This review was generated by QoderWork AI
| role: 'user', | ||
| parts: [ | ||
| { | ||
| text: `I ran the following shell command:\n\`\`\`sh\n${command}\n\`\`\`\n\nThis produced the following result:\n\`\`\`\n${outputText}\n\`\`\``, |
There was a problem hiding this comment.
[Minor] History injection output not fenced against backtick content
Same concern as bridge.ts — the template literal wraps outputText in triple backticks. If outputText itself contains ``` (common in markdown-aware tools or when cat-ing README files), the code fence breaks and the LLM history gets corrupted.
Use dynamic backtick fencing here too (calculate the longest backtick run in outputText and use longestRun + 1 backticks for the fence). See ChannelBase.ts lines 287-293 for the existing implementation.
This review was generated by QoderWork AI
wenshao
left a comment
There was a problem hiding this comment.
R3 Review — COMMENT
9 parallel review agents (correctness, security, code quality, performance, test coverage, attacker, 3AM oncall, maintainer) + batch verification.
High-confidence findings: 0
All R1/R2 findings verified fixed. No new Critical or Suggestion-level issues found.
Needs Human Review (low-confidence, Nice to have)
| # | File | Issue |
|---|---|---|
| 1 | bridge.ts:3263 |
History injection failure logged via writeServeDebugLine() — invisible in production without QWEN_SERVE_DEBUG. Consider daemonLog.warn(). |
| 2 | ChannelBase.ts:283 |
Bang handler doesn't forward AbortSignal to shellCommand(). 120s timeout prevents runaway, but channel disconnect can't cancel mid-command. |
| 3 | bridge.ts:3204 |
shell_output SSE chunks lack command correlation ID — concurrent commands on same session produce unattributable output (rare in practice). |
| 4 | server.ts:1876 |
120s HTTP POST with no timeout indication header — proxies with shorter timeouts may 502. SSE stream compensates with real-time output. |
Rejected (false positives from initial scan)
- AbortSignal race between check and addEventListener — impossible in single-threaded JS (no yield point)
- AnsiOutput inline type fragility — TS structural typing catches relevant changes
- Cross-session shell execution without ownership — same auth pattern as
/promptand/cancel cwddisclosure in SSE — already exposed viaGET /capabilities
Open @chiga0 comments (9 items)
The existing chiga0 inline comments cover the most actionable remaining concerns (backtick fence in acpAgent.ts history injection, timeout ordering, Record cast pattern). These are awaiting author response.
— claude-opus-4 via Qwen Code /review
chiga0
left a comment
There was a problem hiding this comment.
Re-review Summary
Re-evaluated all 9 previous review findings against the current codebase (latest commit 0e83458).
Finding Disposition
| # | Severity | Finding | Verdict |
|---|---|---|---|
| 1 | Major | Unbounded outputChunks buffer | False Positive — 120s SHELL_COMMAND_TIMEOUT_MS caps runtime; history output capped at 10KB. Matches CLI's ShellExecutionService pattern. Author correctly noted this. |
| 2 | Minor | Backtick escaping in LLM history injection (bridge.ts) | Acceptable — CLI uses same pattern; edge case (output containing ```) is rare in practice. Worth a follow-up but non-blocking. |
| 3 | Minor | Timeout starts after await execute() |
Acceptable — Spawn overhead is negligible (<10ms typically). Non-blocking. |
| 4 | Major | Unsafe Record<string, unknown> type cast |
Improved — Commit e3504e8 already switched to typeof === 'function' guard. Full interface approach is nice-to-have refactor, not a blocker. |
| 5 | Minor | Silent fall-through for unsupported ! commands |
Acceptable — Empty ! passing to LLM is debatable design; bridge without shellCommand capability is an edge case. Non-blocking. |
| 6 | Minor | role: 'user' for shell history injection |
Acceptable — Matches CLI's addShellCommandToGeminiHistory format exactly. Consistent with existing pattern. |
| 7 | Nit | command.trim() strips leading whitespace |
Acceptable — Intentional, consistent with CLI behavior. |
| 8 | Nit | Extra blank lines in App.tsx | Nit — Minor style issue, non-blocking. |
| 9 | Minor | Backtick fencing in acpAgent.ts history injection | Acceptable — Same as #2, non-blocking follow-up candidate. |
Conclusion
No blocking issues remain. The PR is well-structured, follows existing codebase patterns (CLI shell execution, ACP extMethod), and all wenshao R1/R2 findings have been addressed. The wenshao R3 review also confirmed 0 high-confidence findings.
Approving.
This review was generated by QoderWork AI
Summary
POST /session/:id/shellroute for direct shell command execution in daemon mode, bypassing the LLM entirelyexecuteShellCommandusesShellExecutionServicewith streaming output viashell_outputSSE eventssessionShellHistoryextMethod injects command+result into LLM history (matching CLI'saddShellCommandToGeminiHistoryformat)shellCommand()onDaemonClient/DaemonSessionClient; newDaemonShellCommandResulttype!handler now callssendShellCommand()instead of wrapping as LLM prompt viaformatShellCommandPrompt!prefix and route through direct executionuser_shell_command/user_shell_resultSSE event types with normalizer supportBefore
!wrapped command as natural-language prompt → LLM used Bash tool (slow, non-deterministic, wasted tokens)!handling at all — raw text passed to LLMAfter
!executes directly viaShellExecutionServiceon the daemon, output streams via SSE, result injected into LLM history for contextTest plan
qwen serve, open web-shell! echo hello— verify output appears immediately (no LLM turn)! exit 42— verify non-zero exit code is displayed! ls, ask LLM "what files do you see?" — verify LLM can reference the shell output! sleep 1 && echo done— verify streaming output arrives progressivelynpx tsc --noEmitfor all modified packages🤖 Generated with Qwen Code