diff --git a/docs/superpowers/plans/2026-07-15-auto-reply.md b/docs/superpowers/plans/2026-07-15-auto-reply.md
new file mode 100644
index 000000000..77da92baa
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-15-auto-reply.md
@@ -0,0 +1,174 @@
+# Auto Reply Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a frontend-only Auto Reply engine that recovers unattended agent tasks from 429/503-style interruptions by auto-sending a configured reply (default `继续`) after a visible countdown, with per-conversation enable, settings-managed rules, and loop protection.
+
+**Architecture:** Pure match/storage helpers under `src/lib/auto-reply/` feed a conversation-scoped `useAutoReplyEngine` hook. The engine watches error/status signals, schedules a countdown banner above the composer, and fires through the normal `onSend(PromptDraft)` path. Rules live in app-level localStorage; enable flags are per conversation/draft key.
+
+**Tech Stack:** React, TypeScript, next-intl, Vitest, localStorage, existing shadcn UI (`DropdownMenuCheckboxItem`, `Switch`, `Button`, `Input`, `Select`).
+
+## Global Constraints
+
+- Frontend-only; no backend/Rust protocol changes.
+- Match **error/status signals only** (not assistant body text).
+- Enable scope is **per conversation only** (composer `+` menu).
+- Rule CRUD lives on a **Settings** page.
+- Built-ins: HTTP 429 and HTTP 503 -> reply `继续`; `delayMs=3000`, `cooldownMs=15000`, `maxPerBurst=3`.
+- Built-ins editable, **not deletable**.
+- Pre-send banner above input with live countdown + cancel.
+- Manual user send cancels pending auto-reply.
+- Safe send only when connected-safe and no permission/question dialogs; never while `prompting`.
+- Do not clobber composer draft; send `replyText` as a standalone draft.
+- Prettier: no semicolons, trailing commas `es5`, 2-space indent.
+- i18n key parity across all 10 locales (`en` is source of truth).
+- `docs/superpowers/**` is gitignored — always `git add -f` for plan/spec commits.
+
+---
+
+## File map
+
+| Path | Responsibility |
+| --- | --- |
+| `src/lib/auto-reply/types.ts` | Shared types + builtin seed factory |
+| `src/lib/auto-reply/match.ts` | Pure matching, safety, burst helpers |
+| `src/lib/auto-reply/match.test.ts` | Unit tests for match helpers |
+| `src/lib/auto-reply/storage.ts` | localStorage load/save for settings + enable map |
+| `src/lib/auto-reply/storage.test.ts` | Storage tests |
+| `src/lib/auto-reply/settings-store.ts` | In-memory settings cache + subscribers |
+| `src/hooks/use-auto-reply-engine.ts` | Countdown lifecycle hook |
+| `src/hooks/use-auto-reply-engine.test.ts` | Fake-timer lifecycle tests |
+| `src/components/chat/auto-reply-banner.tsx` | Countdown + stop-notice UI |
+| `src/components/settings/auto-reply-settings.tsx` | Rules CRUD UI |
+| `src/app/settings/auto-reply/page.tsx` | Settings route |
+| `src/components/settings/settings-shell.tsx` | Nav entry |
+| `src/components/chat/conversation-shell.tsx` | Host engine + banner |
+| `src/components/chat/message-input.tsx` | `+` menu toggle |
+| `src/components/chat/chat-input.tsx` | Pass enable props |
+| `src/i18n/messages/*.json` | Strings (10 locales) |
+
+Storage keys:
+- `codeg:auto-reply:settings:v1`
+- `codeg:auto-reply:enabled:v1`
+
+Enable key: prefer `draftStorageKey` when present, else a stable conversation/virtual id.
+
+---
+
+### Task 1: Pure types + match helpers
+
+**Files:**
+- Create: `src/lib/auto-reply/types.ts`
+- Create: `src/lib/auto-reply/match.ts`
+- Test: `src/lib/auto-reply/match.test.ts`
+
+**Interfaces:**
+- Produces: `AutoReplyRule`, `AutoReplySettings`, `AutoReplyMatchKind`, `AutoReplySignal`, `AutoReplySafetyInput`, `createBuiltinRules()`, `normalizeErrorText()`, `buildBurstKey()`, `findMatchingRule()`, `canScheduleAutoReply()`, `isSafeToAutoReply()`, `signalFromSources()`, `buildAutoReplyDraft()`
+
+- [ ] **Step 1: Write failing tests** in `match.test.ts` covering builtins, http_status, error_text first-match, burst key, cooldown/maxPerBurst, safety gates, signal source preference.
+- [ ] **Step 2: Run** `pnpm exec vitest run src/lib/auto-reply/match.test.ts` (expect FAIL).
+- [ ] **Step 3: Implement** types + match helpers as specified in design.
+- [ ] **Step 4: Re-run tests** (expect PASS).
+- [ ] **Step 5: Commit** `feat(auto-reply): add match helpers and builtin 429/503 rules`
+
+Builtin rule ids: `builtin-http-429`, `builtin-http-503`.
+`isSafeToAutoReply`: status must be `"connected"` and no pending permission/question/ask-question.
+`error_text` match is case-sensitive substring.
+Burst key: `` `${httpStatus ?? "none"}|${normalize(errorText)}` ``.
+
+---
+
+### Task 2: Storage + settings store
+
+**Files:**
+- Create: `src/lib/auto-reply/storage.ts`
+- Create: `src/lib/auto-reply/settings-store.ts`
+- Test: `src/lib/auto-reply/storage.test.ts`
+
+- [ ] **Step 1: Tests** for defaults, corrupt JSON, round-trip, enable map, re-inject missing builtins.
+- [ ] **Step 2: Implement** localStorage helpers + `useSyncExternalStore` settings store.
+- [ ] **Step 3: Pass tests + commit** `feat(auto-reply): persist rules and per-conversation enable flags`
+
+---
+
+### Task 3: Engine hook
+
+**Files:**
+- Create: `src/hooks/use-auto-reply-engine.ts`
+- Test: `src/hooks/use-auto-reply-engine.test.ts`
+
+API:
+
+```ts
+useAutoReplyEngine({
+ enabled, status, error, claudeApiRetry,
+ pendingPermission, pendingQuestion, pendingAskQuestion,
+ onSend,
+}): {
+ pending, stopNotice, cancelPending, notifyManualSend, dismissStopNotice
+}
+```
+
+- [ ] **Step 1: Fake-timer tests** for disable, schedule/fire, cancel, manual send, unsafe cancel, signal clear, cooldown, maxPerBurst, new burst.
+- [ ] **Step 2: Implement hook** with refs for timers/burst counters; re-check safety at fire.
+- [ ] **Step 3: Commit** `feat(auto-reply): add countdown engine with loop protection`
+
+---
+
+### Task 4: i18n (all 10 locales)
+
+- SettingsShell.nav.auto_reply
+- AutoReplySettings.* (settings page strings)
+- Folder.chat.messageInput.autoReply (+ hint)
+- Folder.chat.autoReply.* (banner/stop notice)
+
+- [ ] Add keys to en + zh-CN with real copy; other locales can mirror EN but must keep parity.
+- [ ] Run `pnpm exec vitest run src/i18n/messages.test.ts`
+- [ ] Commit `feat(auto-reply): add i18n strings for composer and settings`
+
+---
+
+### Task 5: Banner + shell / menu wiring
+
+- Create `auto-reply-banner.tsx`
+- Wire engine in `conversation-shell.tsx` (banner closest to input)
+- Wrap composer send to call `notifyManualSend`
+- Pass enable state through chat-input -> message-input
+- `+` menu `DropdownMenuCheckboxItem` toggle; tint `+` when enabled
+
+- [ ] Implement + commit `feat(auto-reply): wire countdown banner and + menu toggle`
+
+---
+
+### Task 6: Settings page + nav
+
+- `src/components/settings/auto-reply-settings.tsx`
+- `src/app/settings/auto-reply/page.tsx`
+- Nav item near Quick Messages; builtins not deletable; delay/cooldown in seconds UI
+
+- [ ] Implement + commit `feat(auto-reply): add settings page for rule management`
+
+---
+
+### Task 7: Verify + bilingual PR
+
+```bash
+pnpm exec vitest run src/lib/auto-reply src/hooks/use-auto-reply-engine.test.ts src/i18n/messages.test.ts
+git add -f docs/superpowers/plans/2026-07-15-auto-reply.md
+git push -u origin feat/auto-reply
+gh pr create --base main --head feat/auto-reply --title "feat: auto-reply for recoverable agent interruptions (429/503)" --body "..."
+```
+
+PR body must include Chinese + English sections: Summary, Motivation, Behavior, Test plan.
+
+---
+
+## Self-review
+
+1. Spec coverage: enable toggle, settings rules, 429/503 builtins, delay, banner, cancel, manual-send cancel, cooldown/maxPerBurst, normal onSend, bilingual PR — covered.
+2. No intentional placeholders.
+3. Shared types/names consistent across tasks.
+
+## Execution note
+
+User has repeatedly said 继续 — execute inline on `feat/auto-reply` after committing this plan. Do not restore the repeat-intent stash onto this branch.
diff --git a/docs/superpowers/specs/2026-07-15-auto-reply-design.md b/docs/superpowers/specs/2026-07-15-auto-reply-design.md
new file mode 100644
index 000000000..c230beda6
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-15-auto-reply-design.md
@@ -0,0 +1,300 @@
+# Auto Reply Design
+
+**Date:** 2026-07-15
+**Status:** Approved for implementation planning
+**Scope:** Chat composer + conversation shell + settings (frontend only)
+**Approach:** Frontend-only rule engine watching error/status signals; per-conversation enable; normal user-send path
+
+## Problem
+
+Unattended agent tasks often stop on recoverable interruptions such as:
+
+- `429 Too Many Requests`
+- `503 Service Unavailable`
+- Claude API retry / connection error surfaces that leave the session idle until a human types something like `继续`
+
+If those interruptions are auto-handled with a delayed, explicit user-visible reply, unattended success rate improves without hiding what the client is about to send.
+
+## Goals
+
+1. Support configurable auto-reply **rules** (match condition, reply text, delay).
+2. Ship built-in rules for **HTTP 429** and **HTTP 503** that reply with **`继续`**.
+3. Support per-rule **delay** before send.
+4. Allow enabling auto-reply **manually from the composer `+` menu**, scoped **per conversation**.
+5. Show a clear **pre-send banner above the input** before any automatic message is sent.
+6. Prevent infinite loops via **cooldown + max fires per interruption burst**.
+7. Reuse the normal composer send path (no special system message type).
+8. Cover matching, lifecycle, loop protection, and UI smoke with tests.
+9. Deliver a PR with bilingual (EN + ZH) description.
+
+## Non-goals
+
+- Backend/Rust-owned auto-send.
+- Matching assistant chat body text.
+- Global enable (or global default + per-conversation override).
+- Cross-device rule sync via backend settings API.
+- Auto-answering permission / free-text question / ask-question dialogs.
+- Inventing a separate message role or silent resume protocol.
+
+## Decisions (from product clarification)
+
+| Topic | Decision |
+| --- | --- |
+| Enable scope | Per conversation only |
+| Rule management | Settings page |
+| Match source | Error / status signals only |
+| Cancel during countdown | Banner cancel + user manual send |
+| Loop protection | Cooldown + max per error burst |
+| Architecture | Frontend-only engine (Approach A) |
+
+## Architecture
+
+```
+Settings (rules CRUD)
+ |
+ v
+auto-reply settings store (rules + defaults, app-level)
+ |
+MessageInput + menu --> per-conversation enabled flag
+ |
+useAutoReplyEngine(error / claudeApiRetry / status)
+ |
+ |- match first enabled rule
+ |- schedule countdown (rule.delayMs)
+ |- banner above composer
+ +- on fire -> onSend(rule.replyText) // normal user-send path
+```
+
+### Key integration points
+
+| Area | File(s) | Role |
+| --- | --- | --- |
+| Retry / error state | `src/contexts/acp-connections-context.tsx` | Source of `ClaudeApiRetryState` (`errorStatus`, `error`) and connection `error` / `status` |
+| Composer dock | `src/components/chat/conversation-shell.tsx` | Host countdown banner above input |
+| `+` menu toggle | `src/components/chat/message-input.tsx` | Per-conversation enable |
+| Send path | existing `onSend` / queue plumbing | Auto reply sends as a normal user draft |
+| Settings shell | `src/components/settings/settings-shell.tsx` + new settings page | Rule CRUD |
+| i18n | `src/i18n/messages/*.json` | EN/ZH (and locale key parity as required by repo) |
+
+## Data model
+
+```ts
+type AutoReplyMatchKind =
+ | "http_status" // exact HTTP status from ClaudeApiRetryState.errorStatus
+ | "error_text" // substring match against retry.error / connection error
+
+interface AutoReplyRule {
+ id: string
+ name: string
+ enabled: boolean
+ matchKind: AutoReplyMatchKind
+ matchValue: string // "429" | "503" | "Too Many Requests" | ...
+ replyText: string // default "继续"
+ delayMs: number
+ cooldownMs: number
+ maxPerBurst: number
+ builtin?: boolean // shipped rules: editable, not deletable
+}
+
+interface AutoReplySettings {
+ version: 1
+ rules: AutoReplyRule[]
+}
+
+// Per conversation:
+// conversationId -> enabled: boolean
+```
+
+### Built-in seed rules
+
+| Name | Match | Reply | delayMs | cooldownMs | maxPerBurst |
+| --- | --- | --- | --- | --- | --- |
+| HTTP 429 | `http_status=429` | `继续` | 3000 | 15000 | 3 |
+| HTTP 503 | `http_status=503` | `继续` | 3000 | 15000 | 3 |
+
+Users may edit delay/reply/cooldown/max and disable builtins, but cannot delete them.
+
+### Persistence
+
+- **Rules:** app-level `localStorage` under a versioned key, e.g. `codeg:auto-reply:settings:v1`
+- **Enable flag:** per conversation id (and draft/storage key for unsaved drafts if a stable key already exists for that composer instance)
+- No backend schema change in v1
+
+## Trigger flow
+
+### When evaluation runs
+
+Re-evaluate when any of these change:
+
+- per-conversation Auto Reply enabled
+- `claudeApiRetry`
+- connection `error`
+- connection `status`
+- rules list from settings
+- pending permission / question / ask-question state (safety gates)
+
+### Match algorithm
+
+1. Skip if Auto Reply is **off** for this conversation.
+2. Skip if a countdown is already scheduled for this conversation.
+3. Skip if connection is not safe to send:
+ - status is `prompting`, `connecting`, `disconnected`, or `error` in a non-recoverable way that cannot accept a prompt
+ - pending permission, free-text question, or ask-question dialog is open
+4. Build signal snapshot:
+ - `httpStatus = claudeApiRetry?.errorStatus`
+ - `errorText = claudeApiRetry?.error ?? connection.error ?? ""`
+5. Walk **enabled rules** in list order; first match wins.
+ - `http_status`: `Number(matchValue) === httpStatus`
+ - `error_text`: case-sensitive substring of `errorText` containing `matchValue` (document exact policy in code; keep simple)
+6. Apply loop protection for that rule + conversation:
+ - if now < lastSuccessfulSendAt + `cooldownMs` -> skip
+ - if same interruption burst already reached `maxPerBurst` -> skip and surface a dismissible stop notice
+7. Start countdown for `rule.delayMs`.
+
+### Interruption burst identity
+
+```ts
+burstKey = `${httpStatus ?? "none"}|${normalize(errorText)}`
+```
+
+`normalize` trims and collapses internal whitespace. Same outage retries share a burst. When the signal clears and a later distinct signal appears, a new burst may fire again.
+
+### Countdown lifecycle
+
+```
+matched
+ -> pending { ruleId, replyText, fireAt, burstKey, matchedLabel }
+ -> banner visible with live remaining seconds
+ -> timer fires
+ -> if still enabled + still safe + signal still relevant
+ -> onSend(plain text draft of replyText)
+ -> record lastSentAt + increment burst count
+ -> else cancel without sending
+```
+
+### Cancel conditions
+
+Cancel pending auto-send (do not send) when:
+
+- user clicks **Cancel** on the banner
+- user **manually sends** a message
+- Auto Reply is toggled off
+- engine unmounts / conversation context is replaced
+- connection becomes unsafe (`prompting`, disconnected, pending dialogs)
+- matched signal disappears before fire (avoid sending after recovery)
+
+### Send semantics
+
+- Auto reply is a **normal user prompt** through the existing `onSend` (or queue-when-busy) path.
+- Do **not** invent a system/hidden message type.
+- Do **not** clobber composer draft contents; send `replyText` directly, independent of the editor buffer.
+- If the session is busy and the product already queues user sends, auto-reply should follow the same busy-send policy as a manual send of that text. Prefer not to invent a second queue policy.
+
+## UI / UX
+
+### Composer `+` menu
+
+Add a menu item in the existing add-actions dropdown:
+
+- Label: `自动回复` / `Auto Reply`
+- Checked / on-off for **this conversation only**
+- Click toggles enable; does not open settings
+- Optional secondary entry: "Manage rules..." -> Settings -> Auto Reply (nice-to-have)
+
+When enabled, show a low-noise indicator on the `+` control (tint or small badge) so unattended mode is visible without opening the menu.
+
+### Pre-send countdown banner (required)
+
+Render above the input, same width as the composer dock:
+
+**zh-CN**
+
+```
+即将自动回复「继续」· 3s [取消]
+匹配:HTTP 429
+```
+
+**en**
+
+```
+Auto-replying "continue" in 3s [Cancel]
+Matched: HTTP 429
+```
+
+Notes:
+
+- Live countdown
+- Quote the exact reply text
+- Info/warning styling, distinct from the existing Claude API retry destructive strip
+- If both exist, auto-reply banner stays closest to the input (action context)
+
+### Settings page
+
+New settings nav item (near Quick Messages):
+
+- Rule list (builtins first)
+- Per rule: enable, name, match kind, match value, reply text, delay (seconds UI), cooldown, max per burst
+- Builtin badge; builtins not deletable
+- Custom rules: add / delete / reorder (first match wins)
+- Help text: matches error/status signals only; per-conversation enable lives in composer `+` menu
+
+### States
+
+| State | UI |
+| --- | --- |
+| Off for conversation | No banner; menu unchecked |
+| On, no match | No banner |
+| On, countdown | Banner + cancel |
+| Hit maxPerBurst | Dismissible notice: auto-reply stopped for this burst |
+
+## Safety defaults
+
+| Field | Builtin default |
+| --- | --- |
+| `delayMs` | 3000 |
+| `cooldownMs` | 15000 |
+| `maxPerBurst` | 3 |
+
+## Testing
+
+1. **Rule matching**
+ - 429 / 503 http status
+ - error_text substring
+ - disabled rules ignored
+ - first match wins
+2. **Engine lifecycle**
+ - disabled conversation never schedules
+ - schedule uses `delayMs`
+ - cancel / manual send prevents send
+ - fire only when still safe
+3. **Loop protection**
+ - cooldown blocks re-fire
+ - maxPerBurst stops burst
+ - new burst can fire again
+4. **UI smoke**
+ - `+` toggle flips per-conversation state
+ - banner copy + cancel
+ - settings edit of builtin delay/reply
+
+## PR delivery
+
+- Branch from `origin/main` (do not stack on unrelated feature branches).
+- Implementation plan under `docs/superpowers/plans/`.
+- PR description includes **Chinese + English** sections:
+ - Summary / 摘要
+ - Motivation / 动机
+ - Behavior / 行为
+ - Test plan / 测试计划
+
+## Success criteria
+
+- With Auto Reply on, an unattended conversation recovering from 429/503 sends `继续` after the configured delay.
+- User always sees an explicit pre-send warning and can cancel.
+- Persistent rate limits do not produce an infinite auto-send loop.
+- No backend protocol changes required for v1.
+
+## Open implementation notes
+
+- Prefer pure helpers (`matchAutoReplyRule`, `shouldScheduleAutoReply`, burst key) for unit tests without mounting the full composer.
+- Prefer a small dedicated store/hook module under `src/lib/auto-reply/` or `src/hooks/use-auto-reply.ts` + `src/stores/` only if an existing local pattern fits better.
+- Follow existing i18n key parity rules used by the repo for settings/composer strings.
diff --git a/src/app/settings/auto-reply/page.tsx b/src/app/settings/auto-reply/page.tsx
new file mode 100644
index 000000000..93c18f9c2
--- /dev/null
+++ b/src/app/settings/auto-reply/page.tsx
@@ -0,0 +1,5 @@
+import { AutoReplySettings } from "@/components/settings/auto-reply-settings"
+
+export default function SettingsAutoReplyPage() {
+ return
+}
diff --git a/src/components/chat/auto-reply-banner.tsx b/src/components/chat/auto-reply-banner.tsx
new file mode 100644
index 000000000..088af5bb2
--- /dev/null
+++ b/src/components/chat/auto-reply-banner.tsx
@@ -0,0 +1,96 @@
+"use client"
+
+import { useTranslations } from "next-intl"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+import type {
+ AutoReplyPendingState,
+ AutoReplyStopNotice,
+} from "@/hooks/use-auto-reply-engine"
+
+interface AutoReplyBannerProps {
+ pending: AutoReplyPendingState | null
+ stopNotice: AutoReplyStopNotice | null
+ onCancel: () => void
+ onDismissStopNotice: () => void
+ className?: string
+}
+
+export function AutoReplyBanner({
+ pending,
+ stopNotice,
+ onCancel,
+ onDismissStopNotice,
+ className,
+}: AutoReplyBannerProps) {
+ const t = useTranslations("Folder.chat.autoReply")
+
+ if (pending) {
+ const seconds = Math.max(1, Math.ceil(pending.remainingMs / 1000))
+ return (
+
+
+
+
+ {t("bannerTitle", {
+ reply: pending.replyText,
+ seconds,
+ })}
+
+
+ {t("bannerMatched", { label: pending.matchedLabel })}
+
+
+
+
+
+ )
+ }
+
+ if (stopNotice) {
+ return (
+
+
+
+
{t("stopNotice")}
+
+ {t("bannerMatched", { label: stopNotice.matchedLabel })}
+
+
+
+
+
+ )
+ }
+
+ return null
+}
diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx
index cfcbdacad..2e82acc18 100644
--- a/src/components/chat/chat-input.tsx
+++ b/src/components/chat/chat-input.tsx
@@ -58,6 +58,8 @@ interface ChatInputProps {
onForkSend?: (draft: PromptDraft, modeId?: string | null) => void
onAddFeedback?: () => void
feedbackAddDisabled?: boolean
+ autoReplyEnabled?: boolean
+ onAutoReplyEnabledChange?: (enabled: boolean) => void
/**
* Keep the composer usable even while disconnected. Set for a folderless chat
* draft: it has no working dir yet (so it never auto-connects), and the FIRST
@@ -113,6 +115,8 @@ export const ChatInput = memo(function ChatInput({
onForkSend,
onAddFeedback,
feedbackAddDisabled,
+ autoReplyEnabled = false,
+ onAutoReplyEnabledChange,
allowOfflineCompose = false,
injectContent,
onInjectConsumed,
@@ -177,6 +181,8 @@ export const ChatInput = memo(function ChatInput({
onForkSend={onForkSend}
onAddFeedback={onAddFeedback}
feedbackAddDisabled={feedbackAddDisabled}
+ autoReplyEnabled={autoReplyEnabled}
+ onAutoReplyEnabledChange={onAutoReplyEnabledChange}
injectContent={injectContent}
onInjectConsumed={onInjectConsumed}
placeholder={
diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx
index c976af0bf..33fe08c02 100644
--- a/src/components/chat/conversation-shell.tsx
+++ b/src/components/chat/conversation-shell.tsx
@@ -1,4 +1,10 @@
-import { useMemo, type ReactNode } from "react"
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react"
import { useTranslations } from "next-intl"
import type {
AgentType,
@@ -23,6 +29,12 @@ import { ChatInput } from "@/components/chat/chat-input"
import { PermissionDialog } from "@/components/chat/permission-dialog"
import { QuestionDialog } from "@/components/chat/question-dialog"
import { AskQuestionCard } from "@/components/chat/ask-question-card"
+import { AutoReplyBanner } from "@/components/chat/auto-reply-banner"
+import { useAutoReplyEngine } from "@/hooks/use-auto-reply-engine"
+import {
+ isAutoReplyEnabled,
+ setAutoReplyEnabled,
+} from "@/lib/auto-reply/storage"
interface ConversationShellProps {
status: ConnectionStatus | null
@@ -194,6 +206,62 @@ export function ConversationShell({
})
}, [claudeApiRetry, tAcp])
+ const enableKey = draftStorageKey ?? "unknown"
+ const [autoReplyEnabled, setAutoReplyEnabledState] = useState(false)
+
+ useEffect(() => {
+ setAutoReplyEnabledState(isAutoReplyEnabled(enableKey))
+ }, [enableKey])
+
+ const handleAutoReplyEnabledChange = useCallback(
+ (enabled: boolean) => {
+ setAutoReplyEnabled(enableKey, enabled)
+ setAutoReplyEnabledState(enabled)
+ },
+ [enableKey]
+ )
+
+ const handleAutoSend = useCallback(
+ (draft: PromptDraft) => {
+ onSend(draft)
+ },
+ [onSend]
+ )
+
+ const {
+ pending: autoReplyPending,
+ stopNotice: autoReplyStopNotice,
+ cancelPending: cancelAutoReplyPending,
+ notifyManualSend: notifyAutoReplyManualSend,
+ dismissStopNotice: dismissAutoReplyStopNotice,
+ } = useAutoReplyEngine({
+ enabled: autoReplyEnabled,
+ status,
+ error,
+ claudeApiRetry,
+ pendingPermission: pendingPermission != null,
+ pendingQuestion: pendingQuestion != null,
+ pendingAskQuestion:
+ pendingAskQuestion != null && pendingAskQuestion.questions.length > 0,
+ onSend: handleAutoSend,
+ })
+
+ const handleComposerSend = useCallback(
+ (draft: PromptDraft, modeId?: string | null) => {
+ notifyAutoReplyManualSend()
+ onSend(draft, modeId)
+ },
+ [notifyAutoReplyManualSend, onSend]
+ )
+
+ const handleComposerEnqueue = useCallback(
+ (draft: PromptDraft, modeId: string | null) => {
+ notifyAutoReplyManualSend()
+ onEnqueue?.(draft, modeId)
+ },
+ [notifyAutoReplyManualSend, onEnqueue]
+ )
+
return (
{topBanner}
@@ -226,14 +294,24 @@ export function ConversationShell({
{!hideInput && (
+
void
+ autoReplyEnabled?: boolean
+ onAutoReplyEnabledChange?: (enabled: boolean) => void
/** Grey out the live-feedback "+" entry when a note can't be sent right now
* (no active turn / agent lacks the tool). */
feedbackAddDisabled?: boolean
@@ -511,6 +514,8 @@ export function MessageInput({
onCancelQueueEdit,
onForkSend,
onAddFeedback,
+ autoReplyEnabled = false,
+ onAutoReplyEnabledChange,
feedbackAddDisabled,
injectContent,
onInjectConsumed,
@@ -3019,8 +3024,15 @@ export function MessageInput({
disabled={disabled}
variant="ghost"
size="icon-xs"
- className="shrink-0 text-muted-foreground"
- title={t("addActions")}
+ className={cn(
+ "shrink-0 text-muted-foreground",
+ autoReplyEnabled && "text-primary"
+ )}
+ title={
+ autoReplyEnabled
+ ? t("autoReplyEnabledHint")
+ : t("addActions")
+ }
aria-label={t("addActions")}
>
@@ -3110,6 +3122,17 @@ export function MessageInput({
)}
+ {onAutoReplyEnabledChange && (
+
+ onAutoReplyEnabledChange(checked === true)
+ }
+ >
+
+ {t("autoReply")}
+
+ )}
{onAddFeedback && (
({ ...rule }))
+}
+
+function createCustomRule(name: string): AutoReplyRule {
+ return {
+ id: `custom-${randomUUID()}`,
+ name,
+ enabled: true,
+ matchKind: "error_text",
+ matchValue: "",
+ replyText: CONTINUE,
+ delayMs: 3000,
+ cooldownMs: 15000,
+ maxPerBurst: 3,
+ }
+}
+
+export function AutoReplySettings() {
+ const t = useTranslations("AutoReplySettings")
+ const stored = useAutoReplySettings()
+ const [rules, setRules] = useState(() =>
+ cloneRules(stored.rules)
+ )
+ const [selectedId, setSelectedId] = useState(
+ () => stored.rules[0]?.id ?? null
+ )
+ const [saving, setSaving] = useState(false)
+ const [deleteTargetId, setDeleteTargetId] = useState(null)
+
+ const selected = rules.find((rule) => rule.id === selectedId) ?? null
+
+ const updateSelected = (patch: Partial) => {
+ if (!selected) return
+ setRules((prev) =>
+ prev.map((rule) =>
+ rule.id === selected.id
+ ? {
+ ...rule,
+ ...patch,
+ id: rule.id,
+ builtin: rule.builtin,
+ }
+ : rule
+ )
+ )
+ }
+
+ const saveOnce = () => {
+ setSaving(true)
+ try {
+ const next = updateAutoReplySettings({ version: 1, rules })
+ setRules(cloneRules(next.rules))
+ if (!next.rules.some((r) => r.id === selectedId)) {
+ setSelectedId(next.rules[0]?.id ?? null)
+ }
+ toast.success(t("saved"))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleAdd = () => {
+ const rule = createCustomRule(t("newRuleName"))
+ setRules((prev) => [...prev, rule])
+ setSelectedId(rule.id)
+ }
+
+ const handleDelete = () => {
+ if (!deleteTargetId) return
+ const target = rules.find((r) => r.id === deleteTargetId)
+ if (!target || target.builtin) {
+ setDeleteTargetId(null)
+ return
+ }
+ const next = rules.filter((r) => r.id !== deleteTargetId)
+ setRules(next)
+ if (selectedId === deleteTargetId) {
+ setSelectedId(next[0]?.id ?? null)
+ }
+ setDeleteTargetId(null)
+ }
+
+ const move = (id: string, direction: -1 | 1) => {
+ setRules((prev) => {
+ const index = prev.findIndex((r) => r.id === id)
+ if (index < 0) return prev
+ const nextIndex = index + direction
+ if (nextIndex < 0 || nextIndex >= prev.length) return prev
+ const copy = [...prev]
+ const [item] = copy.splice(index, 1)
+ copy.splice(nextIndex, 0, item)
+ return copy
+ })
+ }
+
+ return (
+
+
+
{t("title")}
+
{t("description")}
+
{t("help")}
+
+
+
+
+
+
+
+
+
+ {rules.length === 0 ? (
+
+ {t("empty")}
+
+ ) : (
+ rules.map((rule, index) => (
+
+ ))
+ )}
+
+
+
+ {!selected ? (
+
{t("empty")}
+ ) : (
+
+
+
+
+
+
+ updateSelected({ enabled: checked })
+ }
+ />
+
+
+
+
+ updateSelected({ name: e.target.value })}
+ />
+
+
+
+
+
+
+
+
+
+
+ updateSelected({ matchValue: e.target.value })
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {selected.builtin ? (
+
+ {t("cannotDeleteBuiltin")}
+
+ ) : (
+
setDeleteTargetId(selected.id)}
+ >
+
+ {t("delete")}
+
+ )}
+
+ )}
+
+
+
+
{
+ if (!open) setDeleteTargetId(null)
+ }}
+ >
+
+
+ {t("confirmDeleteTitle")}
+
+ {t("confirmDeleteDescription")}
+
+
+
+ {t("cancel")}
+
+ {t("delete")}
+
+
+
+
+
+ )
+}
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index d889fa072..fc491d735 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -16,6 +16,7 @@ import {
Globe,
Keyboard,
Menu,
+ MessageSquarePlus,
MessageSquareText,
SendHorizontal,
Palette,
@@ -47,6 +48,7 @@ interface SettingsNavItem {
| "skills"
| "skill_packs"
| "quick_messages"
+ | "auto_reply"
| "shortcuts"
| "version_control"
| "chat_channels"
@@ -97,6 +99,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
labelKey: "quick_messages",
icon: MessageSquareText,
},
+ {
+ href: "/settings/auto-reply",
+ labelKey: "auto_reply",
+ icon: MessageSquarePlus,
+ },
{
href: "/settings/shortcuts",
labelKey: "shortcuts",
diff --git a/src/hooks/use-auto-reply-engine.test.ts b/src/hooks/use-auto-reply-engine.test.ts
new file mode 100644
index 000000000..b4add34df
--- /dev/null
+++ b/src/hooks/use-auto-reply-engine.test.ts
@@ -0,0 +1,245 @@
+import { act, renderHook } from "@testing-library/react"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { PromptDraft } from "@/lib/types"
+import {
+ AUTO_REPLY_SETTINGS_KEY,
+ createDefaultAutoReplySettings,
+} from "@/lib/auto-reply/storage"
+import { __resetAutoReplySettingsStoreForTests } from "@/lib/auto-reply/settings-store"
+import { useAutoReplyEngine } from "./use-auto-reply-engine"
+
+function draftTexts(calls: PromptDraft[][]): string[] {
+ return calls.map((args) => args[0]?.displayText ?? "")
+}
+
+function baseArgs(
+ overrides: Partial[0]> = {}
+) {
+ return {
+ enabled: true,
+ status: "connected" as const,
+ error: null,
+ claudeApiRetry: {
+ sessionId: "s1",
+ attempt: 1,
+ maxRetries: 3,
+ error: "Too Many Requests",
+ errorStatus: 429,
+ retryDelayMs: 1000,
+ },
+ pendingPermission: false,
+ pendingQuestion: false,
+ pendingAskQuestion: false,
+ onSend: vi.fn(),
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ window.localStorage.clear()
+ window.localStorage.setItem(
+ AUTO_REPLY_SETTINGS_KEY,
+ JSON.stringify(createDefaultAutoReplySettings())
+ )
+ __resetAutoReplySettingsStoreForTests()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+ window.localStorage.clear()
+ __resetAutoReplySettingsStoreForTests()
+})
+
+describe("useAutoReplyEngine", () => {
+ it("does not schedule when disabled", () => {
+ const onSend = vi.fn()
+ const { result } = renderHook(() =>
+ useAutoReplyEngine(baseArgs({ enabled: false, onSend }))
+ )
+ act(() => {
+ vi.advanceTimersByTime(5000)
+ })
+ expect(result.current.pending).toBeNull()
+ expect(onSend).not.toHaveBeenCalled()
+ })
+
+ it("schedules 429 and sends continue after delayMs", () => {
+ const onSend = vi.fn()
+ const { result } = renderHook(() =>
+ useAutoReplyEngine(baseArgs({ onSend }))
+ )
+ expect(result.current.pending?.replyText).toBe("\u7ee7\u7eed")
+ expect(result.current.pending?.ruleId).toBe("builtin-http-429")
+
+ act(() => {
+ vi.advanceTimersByTime(2999)
+ })
+ expect(onSend).not.toHaveBeenCalled()
+
+ act(() => {
+ vi.advanceTimersByTime(1)
+ })
+ expect(onSend).toHaveBeenCalledTimes(1)
+ expect(draftTexts(onSend.mock.calls)).toEqual(["\u7ee7\u7eed"])
+ expect(result.current.pending).toBeNull()
+ })
+
+ it("cancelPending prevents send", () => {
+ const onSend = vi.fn()
+ const { result } = renderHook(() =>
+ useAutoReplyEngine(baseArgs({ onSend }))
+ )
+ act(() => {
+ result.current.cancelPending()
+ })
+ act(() => {
+ vi.advanceTimersByTime(5000)
+ })
+ expect(onSend).not.toHaveBeenCalled()
+ })
+
+ it("notifyManualSend prevents send", () => {
+ const onSend = vi.fn()
+ const { result } = renderHook(() =>
+ useAutoReplyEngine(baseArgs({ onSend }))
+ )
+ act(() => {
+ result.current.notifyManualSend()
+ })
+ act(() => {
+ vi.advanceTimersByTime(5000)
+ })
+ expect(onSend).not.toHaveBeenCalled()
+ })
+
+ it("cancels when connection becomes unsafe", () => {
+ const onSend = vi.fn()
+ const { result, rerender } = renderHook(
+ (props) => useAutoReplyEngine(props),
+ { initialProps: baseArgs({ onSend }) }
+ )
+ expect(result.current.pending).not.toBeNull()
+ rerender(baseArgs({ onSend, status: "prompting" }))
+ expect(result.current.pending).toBeNull()
+ act(() => {
+ vi.advanceTimersByTime(5000)
+ })
+ expect(onSend).not.toHaveBeenCalled()
+ })
+
+ it("cancels when signal clears before fire", () => {
+ const onSend = vi.fn()
+ const { result, rerender } = renderHook(
+ (props) => useAutoReplyEngine(props),
+ { initialProps: baseArgs({ onSend }) }
+ )
+ expect(result.current.pending).not.toBeNull()
+ rerender(
+ baseArgs({
+ onSend,
+ claudeApiRetry: null,
+ error: null,
+ })
+ )
+ expect(result.current.pending).toBeNull()
+ act(() => {
+ vi.advanceTimersByTime(5000)
+ })
+ expect(onSend).not.toHaveBeenCalled()
+ })
+
+ it("enforces maxPerBurst and surfaces stop notice", () => {
+ const onSend = vi.fn()
+ // maxPerBurst default is 3; fire three times with enough cooldown gap.
+ // Use a short-cooldown custom settings set.
+ const settings = createDefaultAutoReplySettings()
+ settings.rules = settings.rules.map((rule) =>
+ rule.id === "builtin-http-429"
+ ? { ...rule, delayMs: 100, cooldownMs: 0, maxPerBurst: 2 }
+ : rule
+ )
+ window.localStorage.setItem(
+ AUTO_REPLY_SETTINGS_KEY,
+ JSON.stringify(settings)
+ )
+ __resetAutoReplySettingsStoreForTests()
+
+ const { result, rerender } = renderHook(
+ (props) => useAutoReplyEngine(props),
+ { initialProps: baseArgs({ onSend }) }
+ )
+
+ act(() => {
+ vi.advanceTimersByTime(100)
+ })
+ expect(onSend).toHaveBeenCalledTimes(1)
+
+ // Re-introduce the same signal after send cleared pending.
+ rerender(baseArgs({ onSend, claudeApiRetry: null }))
+ rerender(baseArgs({ onSend }))
+ act(() => {
+ vi.advanceTimersByTime(100)
+ })
+ expect(onSend).toHaveBeenCalledTimes(2)
+
+ rerender(baseArgs({ onSend, claudeApiRetry: null }))
+ rerender(baseArgs({ onSend }))
+ act(() => {
+ vi.advanceTimersByTime(100)
+ })
+ expect(onSend).toHaveBeenCalledTimes(2)
+ expect(result.current.stopNotice?.reason).toBe("max_per_burst")
+ })
+
+ it("allows a new burst after the signal changes", () => {
+ const onSend = vi.fn()
+ const settings = createDefaultAutoReplySettings()
+ settings.rules = settings.rules.map((rule) => ({
+ ...rule,
+ delayMs: 50,
+ cooldownMs: 0,
+ maxPerBurst: 1,
+ }))
+ window.localStorage.setItem(
+ AUTO_REPLY_SETTINGS_KEY,
+ JSON.stringify(settings)
+ )
+ __resetAutoReplySettingsStoreForTests()
+
+ const { rerender } = renderHook((props) => useAutoReplyEngine(props), {
+ initialProps: baseArgs({ onSend }),
+ })
+ act(() => {
+ vi.advanceTimersByTime(50)
+ })
+ expect(onSend).toHaveBeenCalledTimes(1)
+
+ // Same 429 signal should be blocked by maxPerBurst=1.
+ rerender(baseArgs({ onSend, claudeApiRetry: null }))
+ rerender(baseArgs({ onSend }))
+ act(() => {
+ vi.advanceTimersByTime(50)
+ })
+ expect(onSend).toHaveBeenCalledTimes(1)
+
+ // Distinct 503 signal is a new burst.
+ rerender(
+ baseArgs({
+ onSend,
+ claudeApiRetry: {
+ sessionId: "s1",
+ attempt: 1,
+ maxRetries: 3,
+ error: "Service Unavailable",
+ errorStatus: 503,
+ retryDelayMs: 1000,
+ },
+ })
+ )
+ act(() => {
+ vi.advanceTimersByTime(50)
+ })
+ expect(onSend).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/hooks/use-auto-reply-engine.ts b/src/hooks/use-auto-reply-engine.ts
new file mode 100644
index 000000000..1cf3c182c
--- /dev/null
+++ b/src/hooks/use-auto-reply-engine.ts
@@ -0,0 +1,361 @@
+"use client"
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import type { ClaudeApiRetryState } from "@/contexts/acp-connections-context"
+import type { ConnectionStatus, PromptDraft } from "@/lib/types"
+import {
+ buildAutoReplyDraft,
+ buildBurstKey,
+ canScheduleAutoReply,
+ findMatchingRule,
+ isSafeToAutoReply,
+ signalFromSources,
+} from "@/lib/auto-reply/match"
+import { useAutoReplySettings } from "@/lib/auto-reply/settings-store"
+import type { AutoReplyRule } from "@/lib/auto-reply/types"
+
+export interface AutoReplyPendingState {
+ ruleId: string
+ replyText: string
+ fireAt: number
+ remainingMs: number
+ matchedLabel: string
+ burstKey: string
+}
+
+export interface AutoReplyStopNotice {
+ reason: "max_per_burst"
+ matchedLabel: string
+}
+
+export interface UseAutoReplyEngineArgs {
+ enabled: boolean
+ status: ConnectionStatus | null
+ error: string | null
+ claudeApiRetry: ClaudeApiRetryState | null
+ pendingPermission: boolean
+ pendingQuestion: boolean
+ pendingAskQuestion: boolean
+ onSend: (draft: PromptDraft) => void
+}
+
+export interface UseAutoReplyEngineResult {
+ pending: AutoReplyPendingState | null
+ stopNotice: AutoReplyStopNotice | null
+ cancelPending: () => void
+ notifyManualSend: () => void
+ dismissStopNotice: () => void
+}
+
+function matchedLabelFor(rule: AutoReplyRule): string {
+ if (rule.matchKind === "http_status") {
+ return `HTTP ${rule.matchValue}`
+ }
+ return rule.name || rule.matchValue
+}
+
+function hasSignalText(text: string): boolean {
+ return text.trim().length > 0
+}
+
+export function useAutoReplyEngine(
+ args: UseAutoReplyEngineArgs
+): UseAutoReplyEngineResult {
+ const settings = useAutoReplySettings()
+ const [pending, setPending] = useState(null)
+ const [stopNotice, setStopNotice] = useState(null)
+ const [nowTick, setNowTick] = useState(() => Date.now())
+
+ const pendingRef = useRef(null)
+ const fireTimerRef = useRef | null>(null)
+ const tickTimerRef = useRef | null>(null)
+ const lastSentAtByRuleRef = useRef