diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx
index 66bb707766b..8b3fb2de373 100644
--- a/apps/desktop/src/components/assistant-ui/thread.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread.tsx
@@ -96,6 +96,7 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
+import { $backgroundResume } from '@/store/background-delegation'
import { $compactionActive } from '@/store/compaction'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
@@ -243,7 +244,7 @@ export const Thread: FC<{
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
- loadingIndicator={loading === 'response' ? : null}
+ loadingIndicator={loading === 'response' ? : }
sessionKey={sessionKey}
/>
{loading === 'session' && }
@@ -422,6 +423,36 @@ const ResponseLoadingIndicator: FC = () => {
)
}
+// Parked-background affordance: a top-level delegate_task runs in the
+// background, so the parent turn ends and the app goes idle while the subagent
+// keeps working and its result re-enters as a fresh turn later. Instead of a
+// spinner (reads as "stuck"), reuse the same compact, centered system-note
+// chrome as the steer / slash-status lines (SystemMessage above) so it sits in
+// the thread like every other meta line. Idle-only (gated upstream). Null when
+// nothing is parked.
+const BackgroundResumeNotice: FC = () => {
+ const { t } = useI18n()
+ const resume = useStore($backgroundResume)
+
+ if (!resume) {
+ return null
+ }
+
+ const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count)
+
+ return (
+
+
+ {label}
+
+ )
+}
+
// Seconds of no visible output (text or part count) before a still-running turn
// is treated as stalled and the thinking indicator returns at the tail.
const STREAM_STALL_S = 2
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index a7021d1a6cc..d0852aee998 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1965,6 +1965,10 @@ export const en: Translations = {
loadingSession: 'Loading session',
showEarlier: 'Show earlier messages',
loadingResponse: 'Hermes is loading a response',
+ resumeWhenBackgroundDone: count =>
+ count === 1
+ ? 'Will resume when the background task finishes'
+ : `Will resume when ${count} background tasks finish`,
thinking: 'Thinking',
today: time => `Today, ${time}`,
yesterday: time => `Yesterday, ${time}`,
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 53680d2334d..b7e5943cd0d 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -2090,6 +2090,10 @@ export const ja = defineLocale({
loadingSession: 'セッションを読み込み中',
showEarlier: '以前のメッセージを表示',
loadingResponse: 'Hermes が応答を読み込み中',
+ resumeWhenBackgroundDone: count =>
+ count === 1
+ ? 'バックグラウンドタスクの完了後に再開します'
+ : `${count} 件のバックグラウンドタスクの完了後に再開します`,
thinking: '考え中',
today: time => `今日 ${time}`,
yesterday: time => `昨日 ${time}`,
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 21b6d43e0e4..436e6422f27 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1622,6 +1622,7 @@ export interface Translations {
loadingSession: string
showEarlier: string
loadingResponse: string
+ resumeWhenBackgroundDone: (count: number) => string
thinking: string
today: (time: string) => string
yesterday: (time: string) => string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index e45e7f649af..c484c7e60c2 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -2027,6 +2027,8 @@ export const zhHant = defineLocale({
loadingSession: '正在載入工作階段',
showEarlier: '顯示較早的訊息',
loadingResponse: 'Hermes 正在載入回覆',
+ resumeWhenBackgroundDone: count =>
+ count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
thinking: '思考中',
today: time => `今天,${time}`,
yesterday: time => `昨天,${time}`,
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 1543e0589a4..b3afaec9cf5 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2139,6 +2139,8 @@ export const zh: Translations = {
loadingSession: '正在加载会话',
showEarlier: '显示更早的消息',
loadingResponse: 'Hermes 正在加载回复',
+ resumeWhenBackgroundDone: count =>
+ count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`,
thinking: '思考中',
today: time => `今天,${time}`,
yesterday: time => `昨天,${time}`,
diff --git a/apps/desktop/src/store/background-delegation.test.ts b/apps/desktop/src/store/background-delegation.test.ts
new file mode 100644
index 00000000000..cd918c681da
--- /dev/null
+++ b/apps/desktop/src/store/background-delegation.test.ts
@@ -0,0 +1,65 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { $backgroundResume } from './background-delegation'
+import { $activeSessionId, $busy } from './session'
+import { $subagentsBySession, type SubagentProgress, type SubagentStreamEntry } from './subagents'
+
+const sub = (over: Partial = {}): SubagentProgress => ({
+ id: over.id ?? 'deleg:1',
+ parentId: null,
+ goal: 'do the thing',
+ status: 'running',
+ taskCount: 1,
+ taskIndex: 0,
+ startedAt: 0,
+ updatedAt: 0,
+ filesRead: [],
+ filesWritten: [],
+ stream: [],
+ ...over
+})
+
+const stream = (text: string): SubagentStreamEntry => ({ at: 0, kind: 'progress', text })
+
+describe('$backgroundResume', () => {
+ beforeEach(() => {
+ $busy.set(false)
+ $activeSessionId.set('s1')
+ $subagentsBySession.set({})
+ })
+
+ it('counts running/queued children for the active session while idle', () => {
+ $subagentsBySession.set({ s1: [sub({ id: 'a' }), sub({ id: 'b', status: 'queued' })] })
+ expect($backgroundResume.get()?.count).toBe(2)
+ })
+
+ it('surfaces the primary child latest stream line as live activity', () => {
+ $subagentsBySession.set({ s1: [sub({ id: 'a', stream: [stream('Searching the web…')] })] })
+ expect($backgroundResume.get()?.activity).toBe('Searching the web…')
+ })
+
+ it('activity is null when no stream line has arrived (UI uses generic copy)', () => {
+ $subagentsBySession.set({ s1: [sub({ id: 'a' })] })
+ expect($backgroundResume.get()?.activity).toBeNull()
+ })
+
+ it('is null while a turn is busy (the turn owns the main loader)', () => {
+ $subagentsBySession.set({ s1: [sub({ id: 'a' })] })
+ $busy.set(true)
+ expect($backgroundResume.get()).toBeNull()
+ })
+
+ it('is null when only terminal children or other sessions have work', () => {
+ $subagentsBySession.set({
+ s1: [sub({ id: 'a', status: 'completed' }), sub({ id: 'b', status: 'failed' })],
+ s2: [sub({ id: 'c' })]
+ })
+ expect($backgroundResume.get()).toBeNull()
+ })
+
+ it('is null when there is no active session', () => {
+ $subagentsBySession.set({ s1: [sub({ id: 'a' })] })
+ $activeSessionId.set(null)
+ expect($backgroundResume.get()).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/store/background-delegation.ts b/apps/desktop/src/store/background-delegation.ts
new file mode 100644
index 00000000000..72819cb74d7
--- /dev/null
+++ b/apps/desktop/src/store/background-delegation.ts
@@ -0,0 +1,48 @@
+import { computed } from 'nanostores'
+
+import { $activeSessionId, $busy } from './session'
+import { $subagentsBySession, type SubagentProgress } from './subagents'
+
+export interface BackgroundResume {
+ /** Latest live activity from the primary child (its newest stream line), or
+ * null when nothing readable has arrived yet — the UI then falls back to the
+ * generic "will resume" copy. */
+ activity: string | null
+ /** Running/queued background children for the active session. */
+ count: number
+}
+
+const RUNNING = (s: SubagentProgress) => s.status === 'running' || s.status === 'queued'
+
+/**
+ * "Parked" background-delegation signal for the active session.
+ *
+ * A top-level `delegate_task` always runs in the background: the parent turn
+ * ends (`$busy` -> false) while the subagent keeps running, and its result
+ * re-enters the conversation as a fresh turn when it finishes. During that
+ * window the app is genuinely idle but work is still happening elsewhere, so we
+ * surface a calm, shimmering status line (its latest activity, or a generic
+ * "will resume" fallback) instead of a spinner that reads as "stuck."
+ *
+ * Null while `$busy`: an active turn already owns the main loader, and subagents
+ * spawned inside a running turn (synchronous orchestrator children) are part of
+ * that turn, not parked background work the user is waiting on.
+ */
+export const $backgroundResume = computed(
+ [$subagentsBySession, $activeSessionId, $busy],
+ (bySession, sid, busy): BackgroundResume | null => {
+ if (busy || !sid) {
+ return null
+ }
+
+ const running = (bySession[sid] ?? []).filter(RUNNING)
+
+ if (running.length === 0) {
+ return null
+ }
+
+ const activity = (running[0]!.stream.at(-1)?.text ?? '').trim() || null
+
+ return { activity, count: running.length }
+ }
+)
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index e87b5bf9639..6a9306768ad 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -334,7 +334,7 @@
/* Paragraph spacing — vertical gap between prose paragraphs, both inside a
markdown block and between consecutive prose parts. Single knob; tweak
freely. */
- --paragraph-gap: 0.45rem;
+ --paragraph-gap: 0.7rem;
--sticky-human-top: 0.23rem;
--file-tree-row-height: 1.375rem;
@@ -890,6 +890,14 @@ code {
margin-block: var(--paragraph-gap) 0;
}
+/* Headings are section breaks, so they own a larger top gap than prose — a
+ leading `# title` (common in re-entered/delegated turns) no longer butts
+ against the block above. Top-owned + bottom snug; the first-child reset
+ below still flushes a leading heading. */
+[data-slot='aui_assistant-message-content'] .aui-md :where(h1, h2, h3, h4) {
+ margin-block: 1rem 0.25rem;
+}
+
/* First rendered element of a prose block is flush — the block-level gap above
(tool / paragraph) already provides the separation. Reach one level deep too:
Streamdown wraps blocks in a `div.space-y-*`, so the real first line is the
diff --git a/cli.py b/cli.py
index 7442bfe5ca2..1a92ae93778 100644
--- a/cli.py
+++ b/cli.py
@@ -10499,6 +10499,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _on_tool_complete(self, tool_call_id: str, function_name: str, function_args: dict, function_result: str):
"""Render file edits with inline diff after write-capable tools complete."""
+ # A top-level delegate_task dispatches in the background and re-enters as
+ # a fresh turn when done. Say so once — no spinner, nothing to poll — so
+ # the idle prompt doesn't read as "nothing happened" (⛓ tracks the work).
+ if function_name == "delegate_task":
+ try:
+ parsed = json.loads(function_result) if isinstance(function_result, str) else (function_result or {})
+ except Exception:
+ parsed = {}
+ if isinstance(parsed, dict) and parsed.get("status") == "dispatched" and parsed.get("mode") == "background":
+ n = parsed.get("count") or 1
+ noun, tail = ("task", "it finishes") if n == 1 else (f"{n} tasks", "they finish")
+ try:
+ _cprint(f"\033[2m\u21a9 Background {noun} running — I'll resume when {tail}. Keep chatting.\033[0m")
+ except Exception:
+ pass
snapshot = self._pending_edit_snapshots.pop(tool_call_id, None)
try:
from agent.display import render_edit_diff_with_delta
diff --git a/tests/cli/test_cli_delegate_background_notice.py b/tests/cli/test_cli_delegate_background_notice.py
new file mode 100644
index 00000000000..23f293c1957
--- /dev/null
+++ b/tests/cli/test_cli_delegate_background_notice.py
@@ -0,0 +1,69 @@
+"""The CLI spells out auto-resume when a delegate_task goes to the background.
+
+A top-level ``delegate_task`` returns a handle immediately and runs the subagent
+in the background; the result re-enters the conversation as a fresh turn when it
+finishes. ``_on_tool_complete`` prints a one-line, no-spinner reassurance at
+dispatch so the idle prompt doesn't read as "nothing happened".
+"""
+
+import json
+
+import cli
+from cli import HermesCLI
+
+
+def _make_cli():
+ cli_obj = HermesCLI.__new__(HermesCLI)
+ cli_obj._pending_edit_snapshots = {}
+ return cli_obj
+
+
+def _capture(monkeypatch):
+ printed: list[str] = []
+ monkeypatch.setattr(cli, "_cprint", lambda text: printed.append(text))
+ return printed
+
+
+def test_background_dispatch_prints_resume_notice(monkeypatch):
+ cli_obj = _make_cli()
+ printed = _capture(monkeypatch)
+
+ result = json.dumps({"status": "dispatched", "mode": "background", "count": 1})
+ cli_obj._on_tool_complete("tc1", "delegate_task", {"goal": "x"}, result)
+
+ joined = "\n".join(printed)
+ assert "resume" in joined.lower()
+ assert "it finishes" in joined
+
+
+def test_background_batch_dispatch_pluralizes(monkeypatch):
+ cli_obj = _make_cli()
+ printed = _capture(monkeypatch)
+
+ result = json.dumps({"status": "dispatched", "mode": "background", "count": 3})
+ cli_obj._on_tool_complete("tc2", "delegate_task", {"tasks": []}, result)
+
+ joined = "\n".join(printed)
+ assert "3 tasks" in joined
+ assert "they finish" in joined
+
+
+def test_synchronous_delegate_result_prints_no_notice(monkeypatch):
+ """A non-background result (e.g. the stateless sync fallback) must not claim
+ a background dispatch."""
+ cli_obj = _make_cli()
+ printed = _capture(monkeypatch)
+
+ result = json.dumps({"results": [{"status": "completed", "summary": "done"}]})
+ cli_obj._on_tool_complete("tc3", "delegate_task", {"goal": "x"}, result)
+
+ assert not any("resume" in p.lower() for p in printed)
+
+
+def test_non_delegate_tool_prints_no_notice(monkeypatch):
+ cli_obj = _make_cli()
+ printed = _capture(monkeypatch)
+
+ cli_obj._on_tool_complete("tc4", "read_file", {"path": "a"}, '{"ok": true}')
+
+ assert not any("resume" in p.lower() for p in printed)
diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx
index de823162df2..c428c9dc55f 100644
--- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx
+++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx
@@ -129,6 +129,41 @@ describe('StatusRule background-subagent indicator', () => {
expect(textContent(element)).not.toContain('⛓')
})
+ it('spells out the auto-resume hint when idle with subagents in flight', () => {
+ const element = StatusRule({
+ ...baseProps,
+ usage: { ...baseProps.usage, active_subagents: 1 }
+ })
+
+ expect(textContent(element)).toContain('resumes when subagent finishes')
+ })
+
+ it('pluralizes the resume hint for multiple in-flight subagents', () => {
+ const element = StatusRule({
+ ...baseProps,
+ usage: { ...baseProps.usage, active_subagents: 3 }
+ })
+
+ expect(textContent(element)).toContain('resumes when 3 subagents finish')
+ })
+
+ it('hides the resume hint mid-turn (a busy turn owns the indicator)', () => {
+ const element = StatusRule({
+ ...baseProps,
+ busy: true,
+ turnStartedAt: Date.now(),
+ usage: { ...baseProps.usage, active_subagents: 2 }
+ })
+
+ expect(textContent(element)).not.toContain('resumes when')
+ })
+
+ it('omits the resume hint when no subagents are running', () => {
+ const element = StatusRule({ ...baseProps })
+
+ expect(textContent(element)).not.toContain('resumes when')
+ })
+
it('drops the subagent segment before the bg segment on a narrow terminal', () => {
// cols=44 is below the subagents breakpoint (92) but the bg breakpoint
// (88) too — both gone. Assert the lower-priority subagent indicator is
diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx
index ed0588f5420..9992b227390 100644
--- a/ui-tui/src/components/appChrome.tsx
+++ b/ui-tui/src/components/appChrome.tsx
@@ -512,6 +512,15 @@ export function StatusRule({
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0
const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(`⛓ ${subagentCount}`))
+ // Parked-background reassurance: a top-level delegate_task runs in the
+ // background, so the turn ends (idle) while the subagent keeps working and its
+ // result re-enters as a fresh turn later. When idle with work still in flight,
+ // spell out that the agent resumes on its own — no spinner, nothing to poll.
+ // Width-budgeted like every tail segment, so it drops first on a tight
+ // terminal where ⛓ already carries the signal.
+ const resumeHintText =
+ subagentCount === 1 ? '↩ resumes when subagent finishes' : `↩ resumes when ${subagentCount} subagents finish`
+ const showResumeHint = !busy && subagentCount > 0 && fits(SEP + stringWidth(resumeHintText))
// Dev-gated readout (HERMES_DEV_CREDITS), lowest priority,
// so it consumes tail budget LAST and drops first on a narrow terminal.
const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText))
@@ -624,6 +633,12 @@ export function StatusRule({
⛓ {subagentCount}
) : null}
+ {showResumeHint ? (
+
+ {' │ '}
+ {resumeHintText}
+
+ ) : null}
{showDevCredits ? (
{' │ '}