diff --git a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md index b6f5ebbab9..9bb7657478 100644 --- a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md +++ b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md @@ -7,13 +7,15 @@ name: | --- | --- | --- | | Claude | `@agentclientprotocol/claude-agent-acp` | none | | Codex | `@agentclientprotocol/codex-acp` | none | -| OpenCode | `opencode-ai` | `acp` | +| OpenCode | `opencode-ai` | `acp --print-logs --log-level ERROR` | | Copilot | `@github/copilot` | `--acp` | | Gemini | `@google/gemini-cli` | `--acp` | Normal capability probes, sessions, container commands, and one-shot inference use `npx --yes --prefer-offline` with the package name and arguments -above. This lets npm reuse its execution cache without making the cache a +above. OpenCode's error-only log flags are part of its managed command so +agentctl can observe terminal provider diagnostics without reading OpenCode's +private log files. This lets npm reuse its execution cache without making the cache a durable installed-version guarantee. Kandev records the version reported by the ACP initialize response instead of inferring it from source. diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go index b78aa64270..5459c4d811 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go @@ -15,7 +15,7 @@ func TestManagedNPMRuntimeContracts(t *testing.T) { }{ {"claude", NewClaudeACP(), "@agentclientprotocol/claude-agent-acp", nil}, {"codex", NewCodexACP(), "@agentclientprotocol/codex-acp", nil}, - {"opencode", NewOpenCodeACP(), "opencode-ai", []string{"acp"}}, + {"opencode", NewOpenCodeACP(), "opencode-ai", []string{"acp", "--print-logs", "--log-level", "ERROR"}}, {"copilot", NewCopilotACP(), "@github/copilot", []string{"--acp"}}, {"gemini", NewGemini(), "@google/gemini-cli", []string{"--acp"}}, } diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index bc59b16c66..8d38bc05a6 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -89,7 +89,10 @@ func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command { } func (a *OpenCodeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { - return ManagedNPMRuntimeSpec{Package: opencodeACPPackage, ACPArgs: []string{"acp"}} + return ManagedNPMRuntimeSpec{ + Package: opencodeACPPackage, + ACPArgs: []string{"acp", "--print-logs", "--log-level", "ERROR"}, + } } func (a *OpenCodeACP) Runtime() *RuntimeConfig { diff --git a/apps/backend/internal/agent/agents/opencode_acp_test.go b/apps/backend/internal/agent/agents/opencode_acp_test.go index f306733cc0..cdc1265e72 100644 --- a/apps/backend/internal/agent/agents/opencode_acp_test.go +++ b/apps/backend/internal/agent/agents/opencode_acp_test.go @@ -10,7 +10,7 @@ import ( func TestOpenCodeACPUsesManagedRuntime(t *testing.T) { a := NewOpenCodeACP() - want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai", "acp"} + want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai", "acp", "--print-logs", "--log-level", "ERROR"} if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) { t.Fatalf("BuildCommand = %#v, want %#v", got, want) diff --git a/apps/backend/internal/agent/runtime/lifecycle/event_types.go b/apps/backend/internal/agent/runtime/lifecycle/event_types.go index 70f6f28b4f..fb604e82a1 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/event_types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/event_types.go @@ -10,18 +10,20 @@ import ( // AgentEventPayload is the payload for agent lifecycle events (started, stopped, ready, completed, failed). type AgentEventPayload struct { - AgentExecutionID string `json:"agent_execution_id"` - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` - AgentProfileID string `json:"agent_profile_id"` - ExecutionProfileID string `json:"execution_profile_id,omitempty"` - ContainerID string `json:"container_id,omitempty"` - Status string `json:"status"` - StartedAt time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` - PromptGeneration uint64 `json:"prompt_generation,omitempty"` + AgentExecutionID string `json:"agent_execution_id"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + AgentProfileID string `json:"agent_profile_id"` + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + ContainerID string `json:"container_id,omitempty"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ProviderError *streams.ProviderError `json:"provider_error,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + PromptGeneration uint64 `json:"prompt_generation,omitempty"` } // AgentStalledPayload describes a prompt that has stopped receiving agent events. @@ -116,17 +118,18 @@ func (p PrepareCompletedEventPayload) GetSessionID() string { // AgentStreamEventData contains the nested event data within AgentStreamEventPayload. type AgentStreamEventData struct { - Type string `json:"type"` - ACPSessionID string `json:"acp_session_id,omitempty"` - Text string `json:"text,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolTitle string `json:"tool_title,omitempty"` - ToolStatus string `json:"tool_status,omitempty"` - Error string `json:"error,omitempty"` - SessionStatus string `json:"session_status,omitempty"` // "resumed" or "new" for session_status events - PromptGeneration uint64 `json:"prompt_generation,omitempty"` - Data interface{} `json:"data,omitempty"` + Type string `json:"type"` + ACPSessionID string `json:"acp_session_id,omitempty"` + Text string `json:"text,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolTitle string `json:"tool_title,omitempty"` + ToolStatus string `json:"tool_status,omitempty"` + Error string `json:"error,omitempty"` + ProviderError *streams.ProviderError `json:"provider_error,omitempty"` + SessionStatus string `json:"session_status,omitempty"` // "resumed" or "new" for session_status events + PromptGeneration uint64 `json:"prompt_generation,omitempty"` + Data interface{} `json:"data,omitempty"` // ParentToolCallID identifies the parent Task tool call when this event // comes from a subagent. Used for visual nesting in the UI. diff --git a/apps/backend/internal/agent/runtime/lifecycle/events.go b/apps/backend/internal/agent/runtime/lifecycle/events.go index 4822eaafdc..13fdc92849 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events.go @@ -92,6 +92,7 @@ func newAgentEventPayload(execution *AgentExecution) AgentEventPayload { AgentExecutionID: execution.ID, TaskID: execution.TaskID, SessionID: execution.SessionID, + AgentID: execution.AgentID, AgentProfileID: execution.officeProfileID(), ExecutionProfileID: execution.AgentProfileID, ContainerID: execution.ContainerID, @@ -99,6 +100,7 @@ func newAgentEventPayload(execution *AgentExecution) AgentEventPayload { StartedAt: execution.StartedAt, FinishedAt: execution.FinishedAt, ErrorMessage: execution.ErrorMessage, + ProviderError: execution.ProviderError, ExitCode: execution.ExitCode, PromptGeneration: execution.promptGeneration, } @@ -184,6 +186,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even ToolTitle: event.ToolTitle, ToolStatus: event.ToolStatus, Error: event.Error, + ProviderError: event.ProviderError, SessionStatus: event.SessionStatus, PromptGeneration: event.PromptGeneration, Data: event.Data, diff --git a/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go b/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go index 3408edae8f..ebd62d7069 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go @@ -1,6 +1,11 @@ package lifecycle -import "testing" +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) func TestAgentEventPayloadSeparatesOfficeAndExecutionProfiles(t *testing.T) { payload := newAgentEventPayload(&AgentExecution{ @@ -13,3 +18,23 @@ func TestAgentEventPayloadSeparatesOfficeAndExecutionProfiles(t *testing.T) { t.Fatalf("execution profile = %q, want concrete CLI profile", payload.ExecutionProfileID) } } + +func TestAgentEventPayloadCarriesProviderErrorAndAgentID(t *testing.T) { + occurred := time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC) + payload := newAgentEventPayload(&AgentExecution{ + ID: "exec-1", + AgentID: "opencode-acp", + ProviderError: &streams.ProviderError{ + Source: streams.ProviderErrorSourceOpenCodeStderr, + ModelID: "kimi-k3", + Message: "5-hour usage limit reached", + OccurredAt: occurred, + }, + }) + if payload.AgentID != "opencode-acp" { + t.Fatalf("agent ID = %q, want opencode-acp", payload.AgentID) + } + if payload.ProviderError == nil || payload.ProviderError.ModelID != "kimi-k3" { + t.Fatalf("provider error = %+v", payload.ProviderError) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/execution_store.go b/apps/backend/internal/agent/runtime/lifecycle/execution_store.go index 4776e65a2f..d572c7813b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/execution_store.go +++ b/apps/backend/internal/agent/runtime/lifecycle/execution_store.go @@ -219,6 +219,8 @@ func (s *ExecutionStore) BeginPrompt(executionID string) (uint64, error) { func beginExecutionPrompt(execution *AgentExecution) uint64 { execution.promptGeneration++ + execution.promptCompletionGeneration = 0 + execution.ProviderError = nil execution.Status = v1.AgentStatusRunning execution.resetActiveTool() return execution.promptGeneration diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_events.go b/apps/backend/internal/agent/runtime/lifecycle/manager_events.go index 06309ea44b..d46d0c19cb 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_events.go @@ -206,6 +206,10 @@ func (m *Manager) claimPromptCompletion( if current != execution || current.promptGeneration != event.PromptGeneration { return } + if current.promptCompletionGeneration == event.PromptGeneration { + return + } + current.promptCompletionGeneration = event.PromptGeneration claimed = true claim.execution = current if isError { @@ -255,6 +259,9 @@ func (m *Manager) finishPromptCompletion( ) { handleCompleteEventSignal(execution, event, isError) if event.PromptGeneration == 0 || isError { + if isError { + setProviderError(execution, event.ProviderError) + } m.handleCompleteEventMarkState(execution, event, isError) if claim.locked { execution.promptLifecycleMu.Unlock() @@ -270,6 +277,18 @@ func (m *Manager) finishPromptCompletion( m.eventPublisher.publishAgentEventPayload(context.Background(), events.AgentReady, claim.readyPayload) } +func setProviderError(execution *AgentExecution, providerError *streams.ProviderError) { + if execution == nil || providerError == nil || !providerError.Valid() { + return + } + copy := *providerError + if providerError.ResetAt != nil { + resetAt := *providerError.ResetAt + copy.ResetAt = &resetAt + } + execution.ProviderError = © +} + // handleCompleteEvent handles a "complete" agent event: flushes buffers, marks state, and signals SendPrompt. func (m *Manager) handleCompleteEvent(execution *AgentExecution, event *agentctl.AgentEvent) bool { isError, stopReason := completeEventResult(event) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go index 788e1d0894..94ae125a52 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go @@ -1363,6 +1363,45 @@ func TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh(t *testing.T) { } } +func TestHandleAgentEvent_DuplicateErrorCannotReplaceProviderError(t *testing.T) { + mgr, _ := createTestManagerWithTracking() + execution := createTestExecution("exec-duplicate-error", "task-1", "session-1") + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + generation, err := mgr.executionStore.BeginPrompt(execution.ID) + if err != nil { + t.Fatalf("begin prompt: %v", err) + } + first := &streams.ProviderError{ + Source: streams.ProviderErrorSourceOpenCodeStderr, + ProviderID: "opencode-go", + ModelID: "kimi-k3", + Message: "5-hour usage limit reached", + OccurredAt: time.Now().UTC(), + } + second := *first + second.Message = "a different provider failure" + + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + PromptGeneration: generation, + ProviderError: first, + Error: first.Message, + Data: map[string]interface{}{"is_error": true}, + }) + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "complete", + PromptGeneration: generation, + ProviderError: &second, + Error: second.Message, + Data: map[string]interface{}{"is_error": true}, + }) + if execution.ProviderError == nil || execution.ProviderError.Message != first.Message { + t.Fatalf("provider error was replaced: %+v", execution.ProviderError) + } +} + // TestHandleAgentEvent_UpdatesLastActivityAt verifies that every agent event // updates the lastActivityAt timestamp for stall detection. func TestHandleAgentEvent_UpdatesLastActivityAt(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go index d5be91d54a..d15bcdfc52 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -150,7 +150,7 @@ func TestBuildAgentCommand_UsesManagedNPMRuntimes(t *testing.T) { { name: "opencode", agent: agents.NewOpenCodeACP(), - want: "npx --yes --prefer-offline opencode-ai acp", + want: "npx --yes --prefer-offline opencode-ai acp --print-logs --log-level ERROR", }, { name: "copilot", diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 43870a51a1..9d115ec032 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -52,15 +52,19 @@ type AgentExecution struct { FinishedAt *time.Time ExitCode *int ErrorMessage string + ProviderError *streams.ProviderError Metadata map[string]interface{} // runtimeEnv is the effective environment used to create the task's // runtime instance. It is kept in memory only so authorized task-scoped // terminals and passthrough processes can inherit the same credentials and // PATH as the agent subprocess without persisting secrets in metadata. - runtimeEnv map[string]string - runtimeEnvMu sync.RWMutex - promptGeneration uint64 - promptLifecycleMu sync.Mutex + runtimeEnv map[string]string + runtimeEnvMu sync.RWMutex + promptGeneration uint64 + // promptCompletionGeneration prevents duplicate terminal events for the + // same prompt from replacing the first terminal outcome or provider error. + promptCompletionGeneration uint64 + promptLifecycleMu sync.Mutex // PrepareResult carries the environment preparation result back to the caller // so it can be persisted synchronously before UpdateTaskSession clobbers metadata. diff --git a/apps/backend/internal/agent/runtime/routingerr/classify_test.go b/apps/backend/internal/agent/runtime/routingerr/classify_test.go index db56348a29..5fea6ccf93 100644 --- a/apps/backend/internal/agent/runtime/routingerr/classify_test.go +++ b/apps/backend/internal/agent/runtime/routingerr/classify_test.go @@ -139,6 +139,18 @@ func TestClassify_InvariantsQuotaLimitedAutoRetryable(t *testing.T) { } } +func TestClassify_OpenCodeUsageLimitIsHighConfidenceQuota(t *testing.T) { + resetInjection() + e := Classify(Input{ + Phase: PhaseStreaming, + ProviderID: "opencode-acp", + Stderr: "AI_APICallError: 5-hour usage limit reached. Resets in 4hr 19min.", + }) + if e.Code != CodeQuotaLimited || e.Confidence != ConfHigh { + t.Fatalf("classification = %+v, want high-confidence quota_limited", e) + } +} + func TestClassify_InjectionShortCircuit(t *testing.T) { resetInjection() t.Setenv("KANDEV_PROVIDER_FAILURES", "claude-acp:quota_limited,codex-acp:auth_required") @@ -163,6 +175,23 @@ func TestClassify_SanitizesRawExcerpt(t *testing.T) { } } +func TestClassify_SanitizesOpenCodeWorkspaceURL(t *testing.T) { + resetInjection() + e := Classify(Input{ + Phase: PhaseStreaming, + ProviderID: "opencode-acp", + Stderr: "stream error https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go", + }) + for _, secret := range []string{"/workspace/", "wrk_01KQM7K5CYT715264YKKFB17ZY"} { + if containsSubstring(e.RawExcerpt, secret) { + t.Fatalf("raw excerpt contains %q: %q", secret, e.RawExcerpt) + } + } + if !containsSubstring(e.RawExcerpt, "https://opencode.ai") { + t.Fatalf("raw excerpt removed safe provider host: %q", e.RawExcerpt) + } +} + func TestError_String(t *testing.T) { e := &Error{Code: CodeAuthRequired, ClassifierRule: "claude.stderr.auth.v1"} if got := e.Error(); got != "auth_required: claude.stderr.auth.v1" { diff --git a/apps/backend/internal/agent/runtime/routingerr/rules.go b/apps/backend/internal/agent/runtime/routingerr/rules.go index a2c00c8ce8..2a450cfaca 100644 --- a/apps/backend/internal/agent/runtime/routingerr/rules.go +++ b/apps/backend/internal/agent/runtime/routingerr/rules.go @@ -25,6 +25,7 @@ var providerRules = map[string][]rule{ mustRule("codex.stderr.model.v1", `(?i)model_not_found`, CodeModelUnavailable, ConfHigh), }, "opencode-acp": { + mustRule("opencode.stderr.usage_limit.v1", `(?i)\b\d+[- ]hour(?:s)?\s+usage\s+limit\s+reached\b`, CodeQuotaLimited, ConfHigh), mustRule("opencode.stderr.quota.v1", `(?i)quota`, CodeQuotaLimited, ConfMedium), mustRule("opencode.stderr.rate.v1", `(?i)rate.?limit`, CodeRateLimited, ConfHigh), mustRule("opencode.stderr.auth.v1", `(?i)unauthorized|invalid token`, CodeAuthRequired, ConfHigh), diff --git a/apps/backend/internal/agent/runtime/routingerr/sanitize.go b/apps/backend/internal/agent/runtime/routingerr/sanitize.go index 2daa9daec1..7fec0f36ae 100644 --- a/apps/backend/internal/agent/runtime/routingerr/sanitize.go +++ b/apps/backend/internal/agent/runtime/routingerr/sanitize.go @@ -13,6 +13,14 @@ type redaction struct { } var redactions = []redaction{ + // Provider diagnostics may include account/workspace links and opaque + // session identifiers. These are not useful recovery details and must not + // cross the lifecycle or message boundaries. + // Keep only the scheme and host. This preserves the existing safe-endpoint + // contract used by MCP diagnostics while dropping paths, query strings, and + // fragments that can carry account or workspace identifiers. + {regexp.MustCompile(`(https?://)(?:[^@\s/]+@)?([^/\s?#]+)[^\s]*`), "$1$2"}, + {regexp.MustCompile(`\b(?:wrk|ses|run)_[A-Za-z0-9_-]+\b`), "[redacted-id]"}, {regexp.MustCompile(`sk-[A-Za-z0-9_-]{12,}`), "sk-" + redactionMask}, {regexp.MustCompile(`github_pat_[A-Za-z0-9_]{50,}`), "github_pat_" + redactionMask}, {regexp.MustCompile(`ghp_[A-Za-z0-9]{30,}`), "ghp_" + redactionMask}, diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 1b14aa0e25..80ed72ec55 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -81,6 +81,22 @@ type StderrProvider interface { GetRecentStderr() []string } +// StderrLineConsumer receives cleaned stderr lines as they are emitted by the +// managed agent process. Implementations must return promptly; a consumer is +// an optional diagnostic signal and must never apply backpressure to the child +// process's stderr pipe. +type StderrLineConsumer interface { + ConsumeStderrLine(line string) +} + +// StderrLineSanitizer projects stderr lines before they are logged, buffered, +// or included in process-exit events. Returning keep=false excludes a line +// from those generic diagnostics while allowing a protocol-specific consumer +// to inspect the original line in memory. +type StderrLineSanitizer interface { + SanitizeStderrLine(line string) (safeLine string, keep bool) +} + // StderrProviderSetter is an optional interface implemented by adapters that can use // stderr output for error context. The process manager checks for this interface // and calls SetStderrProvider if available. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index a3c9a84051..3e52487808 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -292,6 +292,7 @@ type promptTurnState struct { rpcDone chan struct{} abortCh chan struct{} handoffCh chan struct{} + providerErrorCh chan openCodeStderrDiagnostic promptGeneration uint64 allowHandoff bool handedOff bool diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go index 6f8ce98c49..a34199431a 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go @@ -134,7 +134,7 @@ func (a *Adapter) sendPrompt( }) }() - if waitErr := a.waitForPromptRPCAfterUserCancel(turn); waitErr != nil { + if waitErr := a.waitForPromptRPCAfterUserCancel(turn, sessionID); waitErr != nil { promptSpan.RecordError(waitErr) promptSpan.End() a.clearPromptTraceCtx(turn) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go index 6f58a90f10..1bb370ea6e 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go @@ -30,6 +30,7 @@ func newPromptTurnState( rpcDone: make(chan struct{}), abortCh: make(chan struct{}), handoffCh: make(chan struct{}), + providerErrorCh: make(chan openCodeStderrDiagnostic, 1), promptGeneration: promptGeneration, allowHandoff: allowHandoff, } @@ -221,26 +222,43 @@ func waitForPromptRPCAfterCancel(turn *promptTurnState) error { } // waitForPromptRPCAfterUserCancel blocks until the in-flight session/prompt RPC -// finishes. If the user cancels while this RPC is running, it waits briefly for -// the agent to stop; otherwise it abandons the RPC so the prompt gate is released. -func (a *Adapter) waitForPromptRPCAfterUserCancel(turn *promptTurnState) error { +// finishes or a correlated OpenCode provider diagnostic settles it. If the user +// cancels while this RPC is running, it waits briefly for the agent to stop; +// otherwise it abandons the RPC so the prompt gate is released. +func (a *Adapter) waitForPromptRPCAfterUserCancel(turn *promptTurnState, sessionID string) error { if turn == nil { return nil } - select { - case <-turn.rpcDone: - return nil - case <-turn.abortCh: + for { select { case <-turn.rpcDone: return nil - case <-time.After(promptCancelJoinTimeout): - if turn.endTurn != nil { - turn.endTurn(ErrTurnCancelNotAcknowledged) + case diagnostic := <-turn.providerErrorCh: + if sessionID == "" || diagnostic.SessionID != sessionID { + continue + } + providerErr := &providerPromptError{ProviderError: diagnostic.ProviderError} + if turn.endTurn != nil { + turn.endTurn(providerErr) + } + select { + case <-turn.rpcDone: + return providerErr + case <-time.After(promptCancelJoinTimeout): + return providerErr + } + case <-turn.abortCh: + select { + case <-turn.rpcDone: + return nil + case <-time.After(promptCancelJoinTimeout): + if turn.endTurn != nil { + turn.endTurn(ErrTurnCancelNotAcknowledged) + } + a.logger.Warn("in-flight session/prompt did not end after cancel; releasing prompt gate", + zap.Duration("timeout", promptCancelJoinTimeout)) + return errPromptAbandonedAfterCancel } - a.logger.Warn("in-flight session/prompt did not end after cancel; releasing prompt gate", - zap.Duration("timeout", promptCancelJoinTimeout)) - return errPromptAbandonedAfterCancel } } } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel_test.go index 6aa950f8b3..fb3ade2170 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel_test.go @@ -44,7 +44,7 @@ func TestWaitForPromptRPCAfterUserCancel_AbortReleasesWhenRPCStuck(t *testing.T) a.promptTurn = turn close(turn.abortCh) - err := a.waitForPromptRPCAfterUserCancel(turn) + err := a.waitForPromptRPCAfterUserCancel(turn, "") if !errors.Is(err, errPromptAbandonedAfterCancel) { t.Fatalf("expected errPromptAbandonedAfterCancel, got %v", err) } @@ -85,7 +85,7 @@ func TestWaitForPromptRPCAfterUserCancel_CompletesAfterAbort(t *testing.T) { done := make(chan error, 1) go func() { - done <- a.waitForPromptRPCAfterUserCancel(turn) + done <- a.waitForPromptRPCAfterUserCancel(turn, "") }() // Wait until the goroutine is blocked inside the inner select. @@ -148,7 +148,7 @@ func TestWaitForPromptRPCAfterUserCancel_CancelsPromptCtxOnTimeout(t *testing.T) turn.abortCh = make(chan struct{}) close(turn.abortCh) - if err := a.waitForPromptRPCAfterUserCancel(turn); !errors.Is(err, errPromptAbandonedAfterCancel) { + if err := a.waitForPromptRPCAfterUserCancel(turn, ""); !errors.Is(err, errPromptAbandonedAfterCancel) { t.Fatalf("expected errPromptAbandonedAfterCancel, got %v", err) } if !errors.Is(context.Cause(ctx), ErrTurnCancelNotAcknowledged) { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go new file mode 100644 index 0000000000..0cbe061916 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go @@ -0,0 +1,231 @@ +package acp + +import ( + "errors" + "regexp" + "strconv" + "strings" + "time" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "go.uber.org/zap" +) + +const ( + opencodeAgentID = "opencode-acp" + maxProviderMessageBytes = 2048 +) + +var ( + openCodeIdentifierPattern = regexp.MustCompile(`\b(?:wrk|ses|run)_[A-Za-z0-9_-]+\b`) + openCodeURLPattern = regexp.MustCompile(`https?://[^\s]+`) + openCodeResetPattern = regexp.MustCompile(`(?i)\bresets?\s+in\s+(?:(\d+)\s*(?:hours?|hrs?|h))?\s*(?:(\d+)\s*(?:minutes?|mins?|m|min))?`) +) + +type openCodeStderrDiagnostic struct { + SessionID string + ProviderError streams.ProviderError +} + +type providerPromptError struct { + ProviderError streams.ProviderError +} + +func (e *providerPromptError) Error() string { + if e == nil || e.ProviderError.Message == "" { + return "provider stream error" + } + return e.ProviderError.Message +} + +// ProviderErrorFromError extracts the safe provider diagnostic from a prompt +// error without exposing the provider-specific wrapper to lifecycle callers. +func ProviderErrorFromError(err error) *streams.ProviderError { + var providerErr *providerPromptError + if !errors.As(err, &providerErr) || providerErr == nil || !providerErr.ProviderError.Valid() { + return nil + } + copy := providerErr.ProviderError + return © +} + +// ConsumeStderrLine implements adapter.StderrLineConsumer. OpenCode's +// error-only stderr is an optional signal; malformed or late diagnostics are +// dropped and a full prompt channel never applies backpressure to stderr. +func (a *Adapter) ConsumeStderrLine(line string) { + if a.agentID != opencodeAgentID { + return + } + diagnostic, ok := parseOpenCodeStderrLine(line) + if !ok { + return + } + turn := a.currentPromptTurn() + if turn == nil { + return + } + select { + case turn.providerErrorCh <- diagnostic: + default: + a.logger.Debug("dropping provider diagnostic because prompt channel is full", + zap.String("session_id", diagnostic.SessionID)) + } +} + +// SanitizeStderrLine keeps only a normalized provider message for OpenCode's +// generic process diagnostics. Unrecognized records are excluded because they +// may contain private URLs, identifiers, paths, or credentials. +func (a *Adapter) SanitizeStderrLine(line string) (string, bool) { + if a.agentID != opencodeAgentID { + return line, true + } + diagnostic, ok := parseOpenCodeStderrLine(line) + if !ok { + return "", false + } + return diagnostic.ProviderError.Message, true +} + +func parseOpenCodeStderrLine(line string) (openCodeStderrDiagnostic, bool) { + fields, ok := parseOpenCodeLogFields(line) + if !ok || fields["level"] != "ERROR" || fields["message"] != "stream error" { + return openCodeStderrDiagnostic{}, false + } + if strings.EqualFold(fields["small"], "true") || strings.EqualFold(fields["agent"], "title") { + return openCodeStderrDiagnostic{}, false + } + sessionID := fields["session.id"] + if !safeOpenCodeIdentifier(sessionID, "ses_") { + return openCodeStderrDiagnostic{}, false + } + message := sanitizeOpenCodeMessage(fields["error.error"]) + if message == "" { + return openCodeStderrDiagnostic{}, false + } + occurredAt, err := time.Parse(time.RFC3339Nano, fields["timestamp"]) + if err != nil { + return openCodeStderrDiagnostic{}, false + } + + providerError := streams.ProviderError{ + Source: streams.ProviderErrorSourceOpenCodeStderr, + ProviderID: safeOpenCodeField(fields["providerID"]), + ModelID: safeOpenCodeField(fields["modelID"]), + Message: message, + OccurredAt: occurredAt, + } + if resetAt := openCodeResetAt(message, occurredAt); resetAt != nil { + providerError.ResetAt = resetAt + } + return openCodeStderrDiagnostic{SessionID: sessionID, ProviderError: providerError}, true +} + +func parseOpenCodeLogFields(line string) (map[string]string, bool) { + fields := make(map[string]string) + for i := 0; i < len(line); { + for i < len(line) && line[i] == ' ' { + i++ + } + if i == len(line) { + break + } + keyStart := i + for i < len(line) && line[i] != '=' && line[i] != ' ' { + i++ + } + if i == keyStart || i >= len(line) || line[i] != '=' { + return nil, false + } + key := line[keyStart:i] + i++ + value, next, ok := parseOpenCodeFieldValue(line, i) + if !ok { + return nil, false + } + if _, duplicate := fields[key]; duplicate { + return nil, false + } + fields[key] = value + i = next + } + return fields, len(fields) > 0 +} + +func parseOpenCodeFieldValue(line string, start int) (string, int, bool) { + if start >= len(line) { + return "", start, false + } + if line[start] != '"' { + end := start + for end < len(line) && line[end] != ' ' { + end++ + } + if end == start { + return "", end, false + } + return line[start:end], end, true + } + + var value strings.Builder + for i := start + 1; i < len(line); i++ { + switch line[i] { + case '\\': + if i+1 >= len(line) { + return "", i, false + } + value.WriteByte(line[i+1]) + i++ + case '"': + return value.String(), i + 1, true + default: + value.WriteByte(line[i]) + } + } + return "", len(line), false +} + +func sanitizeOpenCodeMessage(message string) string { + message = openCodeURLPattern.ReplaceAllString(message, "") + message = openCodeIdentifierPattern.ReplaceAllString(message, "[redacted]") + message = strings.Join(strings.Fields(message), " ") + message = strings.TrimSpace(strings.TrimRight(message, ".:;,-")) + if len(message) > maxProviderMessageBytes { + message = message[:maxProviderMessageBytes] + } + return message +} + +func safeOpenCodeIdentifier(value, prefix string) bool { + if value == "" || len(value) > 128 || !strings.HasPrefix(value, prefix) { + return false + } + return safeOpenCodeField(value) != "" +} + +func safeOpenCodeField(value string) string { + if value == "" || len(value) > 128 { + return "" + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || strings.ContainsRune("._:/-", r) { + continue + } + return "" + } + return value +} + +func openCodeResetAt(message string, occurredAt time.Time) *time.Time { + matches := openCodeResetPattern.FindStringSubmatch(message) + if len(matches) != 3 || (matches[1] == "" && matches[2] == "") { + return nil + } + hours, _ := strconv.Atoi(matches[1]) + minutes, _ := strconv.Atoi(matches[2]) + if hours == 0 && minutes == 0 { + return nil + } + resetAt := occurredAt.Add(time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute) + return &resetAt +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go new file mode 100644 index 0000000000..1158435811 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go @@ -0,0 +1,248 @@ +package acp + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "testing" + "time" + + sdk "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func TestParseOpenCodeStderrLineAcceptsForegroundStreamError(t *testing.T) { + line := `timestamp=2026-08-02T15:15:44.504Z level=ERROR run=a93db686 message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_03d1ac324ffeA2eAGeBfuq80dq small=false agent=build mode=primary error.error="AI_APICallError: 5-hour usage limit reached. Resets in 4hr 19min. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"` + + diagnostic, ok := parseOpenCodeStderrLine(line) + if !ok { + t.Fatal("parseOpenCodeStderrLine() rejected a foreground stream error") + } + if diagnostic.SessionID != "ses_03d1ac324ffeA2eAGeBfuq80dq" { + t.Fatalf("session ID = %q", diagnostic.SessionID) + } + if diagnostic.ProviderError.Source != "opencode_stderr" { + t.Fatalf("source = %q", diagnostic.ProviderError.Source) + } + if diagnostic.ProviderError.ProviderID != "opencode-go" { + t.Fatalf("provider ID = %q", diagnostic.ProviderError.ProviderID) + } + if diagnostic.ProviderError.ModelID != "kimi-k3" { + t.Fatalf("model ID = %q", diagnostic.ProviderError.ModelID) + } + wantOccurred := time.Date(2026, 8, 2, 15, 15, 44, 504000000, time.UTC) + if !diagnostic.ProviderError.OccurredAt.Equal(wantOccurred) { + t.Fatalf("occurred at = %s, want %s", diagnostic.ProviderError.OccurredAt, wantOccurred) + } + wantReset := wantOccurred.Add(4*time.Hour + 19*time.Minute) + if diagnostic.ProviderError.ResetAt == nil || !diagnostic.ProviderError.ResetAt.Equal(wantReset) { + t.Fatalf("reset at = %v, want %s", diagnostic.ProviderError.ResetAt, wantReset) + } + if strings.Contains(diagnostic.ProviderError.Message, "https://") || + strings.Contains(diagnostic.ProviderError.Message, "wrk_01KQM7K5CYT715264YKKFB17ZY") { + t.Fatalf("message contains private URL or identifier: %q", diagnostic.ProviderError.Message) + } + if diagnostic.ProviderError.Message == "" { + t.Fatal("message is empty") + } +} + +func TestPromptDiagnosticSettlesOnlyMatchingSession(t *testing.T) { + a := newTestAdapter() + turn := &promptTurnState{ + rpcDone: make(chan struct{}), + abortCh: make(chan struct{}), + providerErrorCh: make(chan openCodeStderrDiagnostic, 2), + } + var cancelCause error + turn.endTurn = func(err error) { + cancelCause = err + close(turn.rpcDone) + } + + done := make(chan error, 1) + go func() { done <- a.waitForPromptRPCAfterUserCancel(turn, "ses-current") }() + turn.providerErrorCh <- openCodeStderrDiagnostic{SessionID: "ses-other"} + turn.providerErrorCh <- openCodeStderrDiagnostic{ + SessionID: "ses-current", + ProviderError: streams.ProviderError{ + Source: streams.ProviderErrorSourceOpenCodeStderr, + Message: "5-hour usage limit reached", + OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC), + }, + } + + select { + case err := <-done: + var providerErr *providerPromptError + if !errors.As(err, &providerErr) { + t.Fatalf("error = %v, want providerPromptError", err) + } + if providerErr.ProviderError.Message != "5-hour usage limit reached" { + t.Fatalf("provider message = %q", providerErr.ProviderError.Message) + } + case <-time.After(time.Second): + t.Fatal("matching provider diagnostic did not settle prompt") + } + var cause *providerPromptError + if !errors.As(cancelCause, &cause) { + t.Fatalf("cancel cause = %v, want provider prompt error", cancelCause) + } +} + +type providerErrorFakeAgent struct { + concurrencyFakeAgent + entered chan struct{} +} + +func (f *providerErrorFakeAgent) Prompt(ctx context.Context, _ sdk.PromptRequest) (sdk.PromptResponse, error) { + select { + case f.entered <- struct{}{}: + default: + } + <-ctx.Done() + return sdk.PromptResponse{}, ctx.Err() +} + +func (*providerErrorFakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { + return sdk.NewSessionResponse{SessionId: "ses_current"}, nil +} + +func TestOpenCodeDiagnosticPromptSettlesFromCorrelatedStderr(t *testing.T) { + a := newTestAdapter() + a.agentID = opencodeAgentID + a.normalizer = NewNormalizer(opencodeAgentID) + clientToAgentR, clientToAgentW := io.Pipe() + agentToClientR, agentToClientW := io.Pipe() + fake := &providerErrorFakeAgent{ + concurrencyFakeAgent: concurrencyFakeAgent{ + initialized: make(chan sdk.InitializeRequest, 1), + }, + entered: make(chan struct{}, 1), + } + if err := a.Connect(clientToAgentW, agentToClientR); err != nil { + t.Fatalf("connect adapter: %v", err) + } + _ = sdk.NewAgentSideConnection(fake, agentToClientW, clientToAgentR) + t.Cleanup(func() { + _ = a.Close() + _ = clientToAgentW.Close() + _ = agentToClientW.Close() + }) + + ctx := context.Background() + if err := a.Initialize(ctx); err != nil { + t.Fatalf("initialize: %v", err) + } + sessionID, err := a.NewSession(ctx, nil) + if err != nil { + t.Fatalf("new session: %v", err) + } + + promptDone := make(chan error, 1) + go func() { promptDone <- a.Prompt(ctx, "continue", nil, 17) }() + select { + case <-fake.entered: + case <-time.After(time.Second): + t.Fatal("fake prompt did not start") + } + + line := fmt.Sprintf(`timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=%s small=false agent=build error.error="AI_APICallError: 5-hour usage limit reached. Resets in 4hr 19min."`, sessionID) + a.ConsumeStderrLine(line) + + select { + case err := <-promptDone: + var providerErr *providerPromptError + if !errors.As(err, &providerErr) { + t.Fatalf("prompt error = %v, want providerPromptError", err) + } + if providerErr.ProviderError.ModelID != "kimi-k3" { + t.Fatalf("provider error = %+v", providerErr.ProviderError) + } + case <-time.After(time.Second): + t.Fatal("correlated stderr did not settle the held prompt") + } + for _, event := range drainEvents(a) { + if event.Type == streams.EventTypeComplete { + t.Fatalf("provider failure emitted a complete event: %+v", event) + } + } +} + +func TestParseOpenCodeStderrLineRejectsUntrustedRecords(t *testing.T) { + base := `timestamp=2026-08-02T15:15:44.504Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="provider failed"` + tests := []struct { + name string + line string + }{ + {name: "small", line: strings.Replace(base, "small=false", "small=true", 1)}, + {name: "title agent", line: strings.Replace(base, "agent=build", "agent=title", 1)}, + {name: "wrong level", line: strings.Replace(base, "level=ERROR", "level=INFO", 1)}, + {name: "wrong message", line: strings.Replace(base, `message="stream error"`, `message="stream"`, 1)}, + {name: "missing session", line: strings.Replace(base, " session.id=ses_123", "", 1)}, + {name: "missing error", line: strings.Replace(base, ` error.error="provider failed"`, "", 1)}, + {name: "malformed", line: "level=ERROR message=stream error"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, ok := parseOpenCodeStderrLine(tt.line); ok { + t.Fatal("parseOpenCodeStderrLine() accepted an untrusted record") + } + }) + } +} + +func TestParseOpenCodeStderrLineSanitizesIdentifiersAndBoundsMessage(t *testing.T) { + line := `timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="failed run=run_123 session.id=ses_456 workspace=wrk_789 https://example.test/secret"` + diagnostic, ok := parseOpenCodeStderrLine(line) + if !ok { + t.Fatal("expected diagnostic") + } + for _, secret := range []string{"run_123", "ses_456", "wrk_789", "https://example.test/secret"} { + if strings.Contains(diagnostic.ProviderError.Message, secret) { + t.Fatalf("message contains %q: %q", secret, diagnostic.ProviderError.Message) + } + } +} + +func TestOpenCodeStderrConsumerDropsWhenPromptChannelIsBacklogged(t *testing.T) { + a := newTestAdapter() + a.agentID = opencodeAgentID + a.promptTurn = &promptTurnState{ + providerErrorCh: make(chan openCodeStderrDiagnostic, 1), + } + a.promptTurn.providerErrorCh <- openCodeStderrDiagnostic{} + + line := `timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="provider failed"` + done := make(chan struct{}) + go func() { + a.ConsumeStderrLine(line) + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("backlogged provider diagnostic channel blocked stderr consumption") + } +} + +func TestSanitizeOpenCodeStderrLineProjectsOnlySafeProviderMessage(t *testing.T) { + a := newTestAdapter() + a.agentID = opencodeAgentID + line := `timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="usage limit reached: https://opencode.ai/workspace/wrk_123"` + + safe, keep := a.SanitizeStderrLine(line) + if !keep || safe == "" { + t.Fatalf("safe line = %q, keep = %t", safe, keep) + } + if strings.Contains(safe, "https://") || strings.Contains(safe, "wrk_123") { + t.Fatalf("safe line contains private details: %q", safe) + } + + if safe, keep := a.SanitizeStderrLine("level=ERROR message=title"); keep || safe != "" { + t.Fatalf("unrecognized line = %q, keep = %t", safe, keep) + } +} diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index f7fe824336..da81dc0107 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -557,7 +557,11 @@ func (s *Server) handleWSPrompt(ctx context.Context, msg *ws.Message) *ws.Messag return } s.logger.Error("async prompt failed", zap.Error(err)) - s.procMgr.SendErrorEvent(err.Error(), req.PromptGeneration) + s.procMgr.SendErrorEventWithProviderError( + err.Error(), + req.PromptGeneration, + acptransport.ProviderErrorFromError(err), + ) } }() diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 44042a2b1e..a84a2ed1a3 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -98,8 +98,10 @@ type Manager struct { exitErr atomic.Value // error // Stderr buffering for error context - stderrBuffer []string - stderrMu sync.RWMutex + stderrBuffer []string + stderrMu sync.RWMutex + stderrConsumer adapter.StderrLineConsumer + stderrSanitizer adapter.StderrLineSanitizer // Workspace tracker for git status and file changes workspaceTracker *WorkspaceTracker @@ -1457,6 +1459,16 @@ func (m *Manager) createAdapter() error { if setter, ok := m.adapter.(adapter.StderrProviderSetter); ok { setter.SetStderrProvider(m) } + if consumer, ok := m.adapter.(adapter.StderrLineConsumer); ok { + m.stderrConsumer = consumer + } else { + m.stderrConsumer = nil + } + if sanitizer, ok := m.adapter.(adapter.StderrLineSanitizer); ok { + m.stderrSanitizer = sanitizer + } else { + m.stderrSanitizer = nil + } // Set the permission handler m.adapter.SetPermissionHandler(m.handlePermissionRequest) @@ -1494,11 +1506,23 @@ func (m *Manager) GetUpdates() <-chan adapter.AgentEvent { // SendErrorEvent sends an error event on the updates channel so the // lifecycle manager (and ultimately the UI) learns about the failure. func (m *Manager) SendErrorEvent(errorMessage string, promptGeneration uint64) { + m.SendErrorEventWithProviderError(errorMessage, promptGeneration, nil) +} + +// SendErrorEventWithProviderError sends an error event with optional safe +// provider details. The details are already normalized by the adapter and are +// never populated from the manager's raw stderr ring. +func (m *Manager) SendErrorEventWithProviderError( + errorMessage string, + promptGeneration uint64, + providerError *streams.ProviderError, +) { select { case m.updatesCh <- adapter.AgentEvent{ Type: adapter.EventTypeError, Error: errorMessage, PromptGeneration: promptGeneration, + ProviderError: providerError, }: default: m.logger.Warn("updates channel full, could not send error event") @@ -2064,10 +2088,24 @@ func (m *Manager) readStderr() { scanner := bufio.NewScanner(m.stderr) for scanner.Scan() { - line := scanner.Text() + rawLine := stripANSI(scanner.Text()) + if m.stderrConsumer != nil { + // Protocol-specific consumers inspect the line in memory. Their + // normalized output is projected separately before any generic + // logging, buffering, or process-exit event can expose it. + m.stderrConsumer.ConsumeStderrLine(rawLine) + } + + line, keep := rawLine, true + if m.stderrSanitizer != nil { + line, keep = m.stderrSanitizer.SanitizeStderrLine(rawLine) + } + if !keep || line == "" { + continue + } m.logger.Debug("agent stderr", zap.String("line", line)) - // Buffer the line for error context + // Buffer only the safe projection for error context. m.appendStderr(line) } diff --git a/apps/backend/internal/agentctl/server/process/manager_test.go b/apps/backend/internal/agentctl/server/process/manager_test.go index d4e71f2e96..4584440187 100644 --- a/apps/backend/internal/agentctl/server/process/manager_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_test.go @@ -3,6 +3,7 @@ package process import ( "context" "errors" + "fmt" "io" "os" "os/exec" @@ -55,6 +56,117 @@ func TestPublishMCPAttachmentPublishesWithoutBlocking(t *testing.T) { } } +func TestSendErrorEventWithProviderErrorCarriesSafeDetails(t *testing.T) { + occurred := time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC) + m := &Manager{ + updatesCh: make(chan adapter.AgentEvent, 1), + logger: newTestLogger(t), + } + m.SendErrorEventWithProviderError("provider failed", 7, &streams.ProviderError{ + Source: streams.ProviderErrorSourceOpenCodeStderr, + ModelID: "kimi-k3", + Message: "5-hour usage limit reached", + OccurredAt: occurred, + }) + + event := <-m.updatesCh + if event.PromptGeneration != 7 || event.Error != "provider failed" { + t.Fatalf("event = %+v", event) + } + if event.ProviderError == nil || event.ProviderError.ModelID != "kimi-k3" { + t.Fatalf("provider error = %+v", event.ProviderError) + } +} + +func TestManagerReadStderrDeliversCleanedLinesToOptionalConsumer(t *testing.T) { + consumer := &stderrLineCapture{lines: make(chan string, 2)} + m := &Manager{ + stderr: io.NopCloser(strings.NewReader("\x1b[31mquota\x1b[0m\nplain\n")), + stderrConsumer: consumer, + logger: newTestLogger(t), + } + m.wg.Add(1) + m.readStderr() + + got := []string{<-consumer.lines, <-consumer.lines} + want := []string{"quota", "plain"} + if !slices.Equal(got, want) { + t.Fatalf("consumer lines = %#v, want %#v", got, want) + } + if got := m.GetRecentStderr(); !slices.Equal(got, want) { + t.Fatalf("recent stderr = %#v, want %#v", got, want) + } +} + +type stderrLineSanitizerFunc func(string) (string, bool) + +func (f stderrLineSanitizerFunc) SanitizeStderrLine(line string) (string, bool) { + return f(line) +} + +func TestManagerProcessExitHelper(t *testing.T) { + if os.Getenv("KANDEV_MANAGER_PROCESS_EXIT_HELPER") != "1" { + return + } + fmt.Fprintln(os.Stderr, "workspace=https://opencode.ai/workspace/wrk_private") + os.Exit(7) +} + +func TestManagerProcessExitUsesSanitizedStderr(t *testing.T) { + log, observed := newObservedTestLogger(t) + cmd := exec.Command(os.Args[0], "-test.run=TestManagerProcessExitHelper") + cmd.Env = append(os.Environ(), "KANDEV_MANAGER_PROCESS_EXIT_HELPER=1") + stderr, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("stderr pipe: %v", err) + } + m := &Manager{ + cmd: cmd, + stderr: stderr, + logger: log, + doneCh: make(chan struct{}), + updatesCh: make(chan adapter.AgentEvent, 1), + stderrSanitizer: stderrLineSanitizerFunc(func(string) (string, bool) { + return "OpenCode provider failure", true + }), + groupAliveFn: func(int) bool { return false }, + } + m.status.Store(StatusRunning) + if err := cmd.Start(); err != nil { + t.Fatalf("start process: %v", err) + } + m.wg.Add(2) + go m.readStderr() + m.waitForExit() + m.wg.Wait() + + event := <-m.updatesCh + if strings.Contains(event.Error, "wrk_private") || strings.Contains(event.Error, "https://") { + t.Fatalf("exit error leaked raw stderr: %q", event.Error) + } + data := event.Data + if data == nil { + t.Fatal("event data is nil") + } + recent, ok := data["recent_stderr"].([]string) + if !ok || !slices.Equal(recent, []string{"OpenCode provider failure"}) { + t.Fatalf("recent stderr = %#v", data["recent_stderr"]) + } + for _, entry := range observed.All() { + if strings.Contains(fmt.Sprint(entry.ContextMap()), "wrk_private") { + t.Fatalf("logger captured raw stderr: %v", entry.ContextMap()) + } + } +} + +type stderrLineCapture struct { + lines chan string +} + +func (c *stderrLineCapture) ConsumeStderrLine(line string) { + c.lines <- line +} + func TestPublishMCPAttachmentAttemptCarriesBackendOwnedIdentity(t *testing.T) { m := &Manager{updatesCh: make(chan adapter.AgentEvent, 1), logger: newTestLogger(t)} m.PublishMCPAttachmentAttempt(streams.MCPAttachmentAttempt{ diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index 5fdffc69df..fa4f5cc979 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -171,6 +171,10 @@ type AgentEvent struct { // Error contains error message when Type is "error". Error string `json:"error,omitempty"` + // ProviderError contains a validated, sanitized provider diagnostic for a + // terminal error. Raw stderr never crosses this boundary. + ProviderError *ProviderError `json:"provider_error,omitempty"` + // MCPAttachment contains one safe observation from the MCP attachment // lifecycle when Type is EventTypeMCPAttachment. MCPAttachment *MCPAttachmentEvidence `json:"mcp_attachment,omitempty"` diff --git a/apps/backend/internal/agentctl/types/streams/provider_error.go b/apps/backend/internal/agentctl/types/streams/provider_error.go new file mode 100644 index 0000000000..f2a11c25a4 --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/provider_error.go @@ -0,0 +1,24 @@ +package streams + +import "time" + +const ProviderErrorSourceOpenCodeStderr = "opencode_stderr" + +// ProviderError is the bounded, sanitized provider diagnostic that may cross +// the agentctl boundary. It intentionally contains no raw stderr or provider +// account/workspace identifiers. +type ProviderError struct { + Source string `json:"source,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + ModelID string `json:"model_id,omitempty"` + Message string `json:"message,omitempty"` + OccurredAt time.Time `json:"occurred_at,omitempty"` + ResetAt *time.Time `json:"reset_at,omitempty"` +} + +func (e *ProviderError) Valid() bool { + return e != nil && + e.Source != "" && + e.Message != "" && + !e.OccurredAt.IsZero() +} diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index f6ff30d559..4a3276575d 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" @@ -1352,6 +1353,7 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher. "is_auth_error": authErr, "resume_corrupted": resumeCorrupted, } + applyProviderQuotaMetadata(meta, data) // Include cached auth methods so the frontend can show login options. if authErr { @@ -1378,6 +1380,38 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher. } } +// applyProviderQuotaMetadata promotes only a validated OpenCode terminal +// diagnostic to the specialized recovery surface. Generic prose and provider +// diagnostics from other agents retain the existing error card. +func applyProviderQuotaMetadata(meta map[string]interface{}, data watcher.AgentEventData) bool { + if data.AgentID != "opencode-acp" || data.ProviderError == nil || + data.ProviderError.Source != streams.ProviderErrorSourceOpenCodeStderr || + !data.ProviderError.Valid() { + return false + } + classified := routingerr.Classify(routingerr.Input{ + Phase: routingerr.PhaseStreaming, + ProviderID: data.AgentID, + Stderr: data.ProviderError.Message, + }) + if classified.Code != routingerr.CodeQuotaLimited || classified.Confidence != routingerr.ConfHigh { + return false + } + + meta["failure_kind"] = "provider_quota_limited" + meta["provider_name"] = "OpenCode" + if modelID := routingerr.Sanitize(data.ProviderError.ModelID); modelID != "" { + meta["model_id"] = modelID + } + if resetAt := data.ProviderError.ResetAt; resetAt != nil && !resetAt.IsZero() { + meta["reset_at"] = resetAt.UTC().Format(time.RFC3339) + } + if details := routingerr.Sanitize(data.ProviderError.Message); details != "" { + meta["error_output"] = details + } + return true +} + // isOfficeSession resolves Office ownership through the session's task. // Best-effort: a missing session or task falls back to the Kanban path. func (s *Service) isOfficeSession(ctx context.Context, sessionID string) bool { diff --git a/apps/backend/internal/orchestrator/recovery_actions_test.go b/apps/backend/internal/orchestrator/recovery_actions_test.go index dc0831e6ec..8f8947a988 100644 --- a/apps/backend/internal/orchestrator/recovery_actions_test.go +++ b/apps/backend/internal/orchestrator/recovery_actions_test.go @@ -4,7 +4,9 @@ import ( "context" "strings" "testing" + "time" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/orchestrator/watcher" ) @@ -93,3 +95,46 @@ func TestCreateRecoveryStatusMessage_ResumeCorrupted(t *testing.T) { t.Errorf("expected resume_corrupted=true in metadata, got %v", msg.metadata["resume_corrupted"]) } } + +func TestCreateRecoveryStatusMessage_OpenCodeQuotaCarriesSafeMetadata(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t-quota", "s-quota", "step1") + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + mc := &mockMessageCreator{} + svc.messageCreator = mc + resetAt := time.Date(2026, 8, 2, 19, 34, 44, 0, time.UTC) + + svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{ + TaskID: "t-quota", + SessionID: "s-quota", + AgentID: "opencode-acp", + ErrorMessage: "5-hour usage limit reached", + ProviderError: &streams.ProviderError{ + Source: streams.ProviderErrorSourceOpenCodeStderr, + ProviderID: "opencode-go", + ModelID: "kimi-k3", + Message: "5-hour usage limit reached", + OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC), + ResetAt: &resetAt, + }, + }) + + if len(mc.sessionMessages) != 1 { + t.Fatalf("expected 1 session message, got %d", len(mc.sessionMessages)) + } + meta := mc.sessionMessages[0].metadata + if meta["failure_kind"] != "provider_quota_limited" { + t.Fatalf("failure_kind = %#v", meta["failure_kind"]) + } + if meta["provider_name"] != "OpenCode" || meta["model_id"] != "kimi-k3" { + t.Fatalf("provider metadata = %#v", meta) + } + if meta["reset_at"] != resetAt.Format(time.RFC3339) { + t.Fatalf("reset_at = %#v", meta["reset_at"]) + } + if meta["error_output"] != "5-hour usage limit reached" { + t.Fatalf("error_output = %#v", meta["error_output"]) + } +} diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index b6af1fcbfb..58cfdf7d44 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" @@ -25,14 +26,16 @@ type TaskEventData struct { // AgentEventData contains data from agent events type AgentEventData struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id"` - AgentExecutionID string `json:"agent_execution_id"` - AgentProfileID string `json:"agent_profile_id"` - ExecutionProfileID string `json:"execution_profile_id,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - PromptGeneration uint64 `json:"prompt_generation,omitempty"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + AgentExecutionID string `json:"agent_execution_id"` + AgentID string `json:"agent_id,omitempty"` + AgentProfileID string `json:"agent_profile_id"` + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ProviderError *streams.ProviderError `json:"provider_error,omitempty"` + PromptGeneration uint64 `json:"prompt_generation,omitempty"` } // ACPSessionEventData contains data from ACP session events diff --git a/apps/web/components/task/chat/messages/action-message.test.tsx b/apps/web/components/task/chat/messages/action-message.test.tsx index 8131e60efa..bdb9ed801a 100644 --- a/apps/web/components/task/chat/messages/action-message.test.tsx +++ b/apps/web/components/task/chat/messages/action-message.test.tsx @@ -317,3 +317,62 @@ describe("ActionMessage — missing PR branch", () => { expect(details?.open).toBe(true); }); }); + +describe("ActionMessage — provider quota recovery", () => { + it("renders localized model/reset guidance with collapsed sanitized details", () => { + renderAction( + retryMessage({ + content: "provider quota reached", + metadata: { + variant: "error", + recovery_actions: true, + failure_kind: "provider_quota_limited", + provider_name: "OpenCode", + model_id: "kimi-k3", + reset_at: "2026-08-02T19:34:44Z", + error_output: "5-hour usage limit reached", + actions: [ + { + type: "ws_request", + label: "Resume session", + test_id: RESUME_TEST_ID, + }, + ], + }, + } as Partial), + "WAITING_FOR_INPUT", + ); + + expect(screen.getByTestId("provider-quota-recovery")).toBeTruthy(); + expect(screen.getByText(/OpenCode usage limit reached/i)).toBeTruthy(); + expect(screen.getByText(/kimi-k3/i)).toBeTruthy(); + const details = screen.getByText(TECHNICAL_DETAILS).closest("details"); + expect(details?.open).toBe(false); + expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("min-h-11"); + + fireEvent.click(screen.getByText(TECHNICAL_DETAILS)); + expect(details?.open).toBe(true); + expect(screen.getByText("5-hour usage limit reached")).toBeTruthy(); + expect(screen.queryByText(/opencode\.ai\/workspace/i)).toBeNull(); + }); + + it("uses a safe generic reset message when reset time is absent", () => { + renderAction( + retryMessage({ + content: "provider quota reached", + metadata: { + variant: "error", + recovery_actions: true, + failure_kind: "provider_quota_limited", + provider_name: "OpenCode", + model_id: "kimi-k3", + actions: [], + }, + } as Partial), + "WAITING_FOR_INPUT", + ); + + expect(screen.getByTestId("provider-quota-recovery")).toBeTruthy(); + expect(screen.getByText(/when the provider makes capacity available/i)).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/chat/messages/action-message.tsx b/apps/web/components/task/chat/messages/action-message.tsx index 7e7ccd2d6a..0406aed59b 100644 --- a/apps/web/components/task/chat/messages/action-message.tsx +++ b/apps/web/components/task/chat/messages/action-message.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, memo, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; import { IconAlertTriangle, IconArchive, @@ -24,6 +25,7 @@ import { AuthMethodsPanel, GenericAuthPanel } from "./auth-methods-panel"; import { HostShellDialog } from "@/components/settings/host-shell-dialog"; import type { Message, TaskSessionState } from "@/lib/types/http"; import type { MessageAction, RecoveryAuthMethod } from "@/components/task/chat/types"; +import { formatDateTime } from "@/lib/i18n/formats"; const ICON_MAP: Record = { archive: IconArchive, @@ -46,6 +48,9 @@ type ActionMeta = { error_output?: string; failure_kind?: string; missing_branch?: string; + provider_name?: string; + model_id?: string; + reset_at?: string; }; function isSessionActive(state?: TaskSessionState) { @@ -100,16 +105,14 @@ function SettledActionMessage({ if (isSessionActive(sessionState) || (metadata?.recovery_actions && recoveryRequested)) return null; - if (metadata?.failure_kind === "missing_pr_branch") { - return ( - - ); - } + const specialRecovery = renderSpecialRecovery({ + metadata, + message, + sessionError, + taskId, + onRecoveryRequested: () => setRecoveryRequested(true), + }); + if (specialRecovery) return specialRecovery; const iconClass = metadata?.variant === "warning" ? "text-amber-500" : "text-red-500"; const textClass = @@ -141,6 +144,93 @@ function SettledActionMessage({ ); } +function renderSpecialRecovery({ + metadata, + message, + sessionError, + taskId, + onRecoveryRequested, +}: { + metadata: ActionMeta | undefined; + message: string; + sessionError?: string; + taskId?: string; + onRecoveryRequested: () => void; +}): ReactElement | null { + if (metadata?.failure_kind === "missing_pr_branch") { + return ( + + ); + } + if (metadata?.failure_kind === "provider_quota_limited") { + return ( + + ); + } + return null; +} + +function ProviderQuotaRecovery({ + metadata, + taskId, + onRecoveryRequested, +}: { + metadata: ActionMeta; + taskId?: string; + onRecoveryRequested: () => void; +}) { + const { t } = useTranslation(); + const provider = metadata.provider_name?.trim() || t("chat:providerQuotaProviderFallback"); + const model = metadata.model_id?.trim(); + const resetDate = metadata.reset_at ? new Date(metadata.reset_at) : undefined; + const resetAt = resetDate && !Number.isNaN(resetDate.getTime()) ? formatDateTime(resetDate) : ""; + + return ( +
+
+
+
+ ); +} + function useActionMessageSession(sessionId: Message["session_id"]) { const sessionState = useAppStore((state) => sessionId ? (state.taskSessions.items[sessionId]?.state ?? undefined) : undefined, @@ -223,11 +313,12 @@ function MissingBranchRecovery({ } function TechnicalDetails({ children }: { children: string }) { + const { t } = useTranslation(); return (
- Technical details + {t("chat:technicalDetails")}
         {children}
diff --git a/apps/web/components/task/chat/types.ts b/apps/web/components/task/chat/types.ts
index 24566d8181..3f0e6a25d2 100644
--- a/apps/web/components/task/chat/types.ts
+++ b/apps/web/components/task/chat/types.ts
@@ -183,6 +183,11 @@ export type RecoveryMetadata = StatusMetadata & {
   has_resume_token: boolean;
   is_auth_error?: boolean;
   auth_methods?: RecoveryAuthMethod[];
+  failure_kind?: "provider_quota_limited" | "missing_pr_branch" | string;
+  provider_name?: string;
+  model_id?: string;
+  reset_at?: string;
+  error_output?: string;
 };
 
 export type MessageAction = {
diff --git a/apps/web/e2e/tests/session/mobile-provider-quota-recovery.spec.ts b/apps/web/e2e/tests/session/mobile-provider-quota-recovery.spec.ts
new file mode 100644
index 0000000000..b71da78cb5
--- /dev/null
+++ b/apps/web/e2e/tests/session/mobile-provider-quota-recovery.spec.ts
@@ -0,0 +1,95 @@
+// Filename starts with "mobile-" so this runs on the mobile-chrome project.
+import { test, expect } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
+import { SessionPage } from "../../pages/session-page";
+
+async function seedProviderQuotaRecovery(apiClient: ApiClient, seedData: SeedData) {
+  const task = await apiClient.createTask(seedData.workspaceId, "Mobile OpenCode quota recovery", {
+    workflow_id: seedData.workflowId,
+    workflow_step_id: seedData.startStepId,
+  });
+  const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
+    state: "FAILED",
+    completedAt: "2026-08-02T15:15:44Z",
+  });
+  await apiClient.seedSessionMessage(sessionId, {
+    type: "status",
+    content: "OpenCode stopped responding after the provider rejected the stream.",
+    metadata: {
+      recovery_actions: true,
+      failure_kind: "provider_quota_limited",
+      provider_name: "OpenCode",
+      model_id: "kimi-k3",
+      reset_at: "2026-08-02T16:30:00Z",
+      error_output: "5-hour usage limit reached. Resets in 4hr 19min.",
+      actions: [
+        {
+          type: "archive_task",
+          label: "Archive task",
+          test_id: "provider-quota-archive-button",
+        },
+        {
+          type: "delete_task",
+          label: "Delete task",
+          test_id: "provider-quota-delete-button",
+          variant: "destructive",
+        },
+      ],
+    },
+  });
+  return task;
+}
+
+test("keeps OpenCode quota recovery touch-safe on mobile", async ({
+  testPage,
+  apiClient,
+  seedData,
+  prCapture,
+}, testInfo) => {
+  const task = await seedProviderQuotaRecovery(apiClient, seedData);
+
+  await testPage.goto(`/t/${task.id}`);
+  const session = new SessionPage(testPage);
+  await session.waitForLoad();
+
+  const recovery = session.activeChat().getByTestId("provider-quota-recovery");
+  await expect(recovery).toBeVisible();
+  await expect(
+    recovery.getByRole("heading", { name: "OpenCode usage limit reached" }),
+  ).toBeVisible();
+  await expect(recovery).toContainText("kimi-k3");
+  await expect(recovery).toContainText("Capacity resets");
+
+  const technicalDetails = recovery.locator("details");
+  const technicalOutput = technicalDetails.locator("pre");
+  await expect(technicalDetails).not.toHaveAttribute("open");
+  await recovery.getByText("Technical details", { exact: true }).tap();
+  await expect(technicalOutput).toBeVisible();
+  await expect(technicalOutput).toContainText("5-hour usage limit reached");
+  await expect(technicalOutput).not.toContainText("https://opencode.ai");
+  await expect(technicalOutput).not.toContainText("wrk_");
+  await expect(technicalOutput).not.toContainText("ses_");
+
+  for (const button of [
+    recovery.getByTestId("provider-quota-archive-button"),
+    recovery.getByTestId("provider-quota-delete-button"),
+  ]) {
+    await expect(button).toBeVisible();
+    await expect(button).toBeInViewport();
+    const box = await button.boundingBox();
+    expect(box).not.toBeNull();
+    expect(box!.height).toBeGreaterThanOrEqual(44);
+  }
+
+  await assertNoDocumentHorizontalOverflow(testPage, "mobile provider quota recovery");
+  await testPage.screenshot({
+    path: testInfo.outputPath("provider-quota-recovery-mobile.png"),
+    fullPage: true,
+  });
+  await prCapture.screenshot("provider-quota-recovery-mobile", {
+    caption: "OpenCode quota recovery remains touch-safe on mobile",
+    fullPage: true,
+  });
+});
diff --git a/apps/web/e2e/tests/session/provider-quota-recovery.spec.ts b/apps/web/e2e/tests/session/provider-quota-recovery.spec.ts
new file mode 100644
index 0000000000..54311b87ee
--- /dev/null
+++ b/apps/web/e2e/tests/session/provider-quota-recovery.spec.ts
@@ -0,0 +1,97 @@
+import { test, expect } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
+import { SessionPage } from "../../pages/session-page";
+
+async function seedProviderQuotaRecovery(apiClient: ApiClient, seedData: SeedData) {
+  const task = await apiClient.createTask(seedData.workspaceId, "OpenCode quota recovery", {
+    workflow_id: seedData.workflowId,
+    workflow_step_id: seedData.startStepId,
+  });
+  const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
+    state: "FAILED",
+    completedAt: "2026-08-02T15:15:44Z",
+  });
+  await apiClient.seedSessionMessage(sessionId, {
+    type: "status",
+    content: "OpenCode stopped responding after the provider rejected the stream.",
+    metadata: {
+      recovery_actions: true,
+      failure_kind: "provider_quota_limited",
+      provider_name: "OpenCode",
+      model_id: "kimi-k3",
+      reset_at: "2026-08-02T16:30:00Z",
+      error_output: "5-hour usage limit reached. Resets in 4hr 19min.",
+      actions: [
+        {
+          type: "archive_task",
+          label: "Archive task",
+          test_id: "provider-quota-archive-button",
+        },
+        {
+          type: "delete_task",
+          label: "Delete task",
+          test_id: "provider-quota-delete-button",
+          variant: "destructive",
+        },
+      ],
+    },
+  });
+  return task;
+}
+
+test("renders a localized OpenCode quota recovery card", async ({
+  testPage,
+  apiClient,
+  seedData,
+  prCapture,
+}, testInfo) => {
+  const task = await seedProviderQuotaRecovery(apiClient, seedData);
+
+  await testPage.goto(`/t/${task.id}`);
+  const session = new SessionPage(testPage);
+  await session.waitForLoad();
+
+  const recovery = session.activeChat().getByTestId("provider-quota-recovery");
+  await expect(recovery).toHaveCount(1);
+  await expect(
+    recovery.getByRole("heading", { name: "OpenCode usage limit reached" }),
+  ).toBeVisible();
+  await expect(recovery).toContainText("kimi-k3");
+  await expect(recovery).toContainText("Capacity resets");
+  await expect(session.activeChat().getByRole("status", { name: /Session failed/i })).toHaveCount(
+    0,
+  );
+
+  const technicalDetails = recovery.locator("details");
+  const technicalOutput = technicalDetails.locator("pre");
+  await expect(technicalDetails).not.toHaveAttribute("open");
+  await expect(technicalOutput).not.toBeVisible();
+  await recovery.getByText("Technical details", { exact: true }).click();
+  await expect(technicalOutput).toBeVisible();
+  await expect(technicalOutput).toContainText("5-hour usage limit reached");
+  await expect(technicalOutput).not.toContainText("https://opencode.ai");
+  await expect(technicalOutput).not.toContainText("wrk_");
+  await expect(technicalOutput).not.toContainText("ses_");
+
+  for (const button of [
+    recovery.getByTestId("provider-quota-archive-button"),
+    recovery.getByTestId("provider-quota-delete-button"),
+  ]) {
+    await expect(button).toBeVisible();
+    const box = await button.boundingBox();
+    expect(box).not.toBeNull();
+    expect(box!.height).toBeGreaterThan(0);
+  }
+
+  await assertNoDocumentHorizontalOverflow(testPage, "provider quota recovery");
+  await testPage.screenshot({
+    path: testInfo.outputPath("provider-quota-recovery-desktop.png"),
+    fullPage: true,
+  });
+  await prCapture.screenshot("provider-quota-recovery-desktop", {
+    caption: "OpenCode quota recovery with model and reset guidance",
+    fullPage: true,
+  });
+});
diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json
index c2f6ef707b..3f33713caa 100644
--- a/apps/web/src/locales/en/chat.json
+++ b/apps/web/src/locales/en/chat.json
@@ -5,5 +5,11 @@
   "failedToMergeQueuedMessages": "Failed to merge queued messages.",
   "mergeReferenceOverflow": "Too many entity references to merge these queued messages.",
   "mergeWithAbove": "Merge with above",
-  "removeQueuedMessage": "Remove queued message"
+  "removeQueuedMessage": "Remove queued message",
+  "technicalDetails": "Technical details",
+  "providerQuotaTitle": "{{provider}} usage limit reached",
+  "providerQuotaModel": "Model: {{model}}",
+  "providerQuotaReset": "Capacity resets {{resetAt}}.",
+  "providerQuotaResetUnknown": "Try again when the provider makes capacity available.",
+  "providerQuotaProviderFallback": "Provider"
 }
diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json
index a01902db89..a0f00918c3 100644
--- a/apps/web/src/locales/pseudo/chat.json
+++ b/apps/web/src/locales/pseudo/chat.json
@@ -5,5 +5,11 @@
   "failedToMergeQueuedMessages": "Ƒàĩĺēď ţō ḿēŕĝē qũēũēď ḿēśśàĝēś.",
   "mergeReferenceOverflow": "Ţōō ḿàńŷ ēńţĩţŷ ŕēƒēŕēńćēś ţō ḿēŕĝē ţĥēśē qũēũēď ḿēśśàĝēś.",
   "mergeWithAbove": "Ḿēŕĝē ŵĩţĥ àƀōvē",
-  "removeQueuedMessage": "Ŕēḿōvē qũēũēď ḿēśśàĝē"
+  "removeQueuedMessage": "Ŕēḿōvē qũēũēď ḿēśśàĝē",
+  "technicalDetails": "Ţēćĥńĩćàĺ ďēţàĩĺś",
+  "providerQuotaTitle": "{{provider}} ũśàĝē ĺĩḿĩţ ŕēàćĥēď",
+  "providerQuotaModel": "Ḿōďēĺ: {{model}}",
+  "providerQuotaReset": "Ćàƥàćĩţŷ ŕēśēţś {{resetAt}}.",
+  "providerQuotaResetUnknown": "Ţŕŷ àĝàĩń ŵĥēń ţĥē ƥŕōvĩďēŕ ḿàķēś ćàƥàćĩţŷ àvàĩĺàƀĺē.",
+  "providerQuotaProviderFallback": "Ƥŕōvĩďēŕ"
 }
diff --git a/docs/decisions/2026-07-29-agent-stall-user-controlled-recovery.md b/docs/decisions/2026-07-29-agent-stall-user-controlled-recovery.md
index 0ee7988c47..49a4648d4f 100644
--- a/docs/decisions/2026-07-29-agent-stall-user-controlled-recovery.md
+++ b/docs/decisions/2026-07-29-agent-stall-user-controlled-recovery.md
@@ -30,6 +30,10 @@ Stall detection is advisory and recovery remains operator controlled:
   process. `RUNNING` sessions retain the conservative prompt-admission behavior
   defined by
   [ADR-2026-07-28-coarse-running-busy-signal](2026-07-28-coarse-running-busy-signal.md).
+- This advisory rule applies to inactivity without terminal evidence. A
+  sanitized, session-correlated provider diagnostic follows
+  [ADR-2026-08-02-agent-terminal-diagnostics-over-stderr](2026-08-02-agent-terminal-diagnostics-over-stderr.md)
+  and may fail the prompt immediately.
 - The watchdog checks once per 60 seconds and logs only the first detected stall
   for a prompt generation; it does not repeat a notice every check.
 
diff --git a/docs/decisions/2026-08-02-agent-terminal-diagnostics-over-stderr.md b/docs/decisions/2026-08-02-agent-terminal-diagnostics-over-stderr.md
new file mode 100644
index 0000000000..f1d26aa05d
--- /dev/null
+++ b/docs/decisions/2026-08-02-agent-terminal-diagnostics-over-stderr.md
@@ -0,0 +1,74 @@
+# ADR-2026-08-02-agent-terminal-diagnostics-over-stderr: Capture Agent Terminal Diagnostics From Managed Stderr
+
+**Status:** accepted
+**Date:** 2026-08-02
+**Area:** backend, frontend, protocol, security
+
+## Context
+
+OpenCode can log a terminal provider stream failure while leaving its ACP
+`session/prompt` request open. Kandev then sees an indefinitely quiet running
+turn even though the agent knows it cannot continue. The same diagnostic is
+available in OpenCode's private data-directory log and, when explicitly
+enabled, on the stderr of the `opencode acp` process that agentctl owns.
+
+## Decision
+
+Agent-private files are not a Kandev runtime contract. When an agent supports
+explicit diagnostic emission, Kandev enables error-only output on the managed
+agent subprocess and consumes it inside agentctl, in the executor where the
+process runs.
+
+An out-of-band diagnostic may settle a prompt only when an agent-specific
+parser validates the terminal signature and correlates it to the current agent
+type, active provider session, and active foreground prompt. Parsers retain
+only allowlisted fields, sanitize before crossing the agentctl boundary, and
+never forward raw log lines. Unmatched, malformed, background, stale, or
+unsupported diagnostics are ignored.
+
+For OpenCode, a correlated `stream error` is terminal evidence, not an
+inactivity inference. It cancels the held prompt operation and enters Kandev's
+existing generation-scoped error lifecycle. Silence without such evidence
+continues to follow
+[ADR-2026-07-29-agent-stall-user-controlled-recovery](2026-07-29-agent-stall-user-controlled-recovery.md).
+
+Raw stderr is inspected only in memory by the protocol-specific parser. For
+OpenCode, the process manager applies the parser's safe message projection
+before writing generic logs, the recent-stderr ring, or process-exit events;
+malformed and unrelated records are excluded. Other agents retain their
+existing stderr behavior. Raw OpenCode lines are never persisted, included in
+browser payloads, or exposed through a general API for reading agent logs. The
+observable behavior is specified in
+[`docs/specs/agent-stall-recovery/spec.md`](../specs/agent-stall-recovery/spec.md).
+
+## Consequences
+
+Provider failures that an ACP bridge swallows can become immediate, truthful
+session failures across local, container, and remote executors without granting
+the central backend filesystem access. Existing lifecycle reconciliation,
+recovery messages, and provider-error classification remain the single path
+after agentctl validates the diagnostic.
+
+Kandev takes on a small agent-compatibility parser for each supported stderr
+format. A vendor format change degrades safely to ACP plus advisory stall
+recovery rather than guessing. Enabling unsupported CLI diagnostic flags can
+affect startup, so the managed runtime contract and compatibility tests must
+cover those arguments.
+
+## Alternatives Considered
+
+- **Tail `~/.local/share/opencode/log/opencode.log`.** Rejected because it is a
+  private, rotating, multi-session file containing unrelated paths, URLs, and
+  identifiers, and the central backend cannot access every executor's home.
+- **Expose the recent stderr buffer to the browser.** Rejected because raw
+  stderr is broader than the user-facing failure and may contain sensitive or
+  unrelated diagnostics.
+- **Treat any OpenCode stderr error as terminal.** Rejected because title,
+  background, stale, and concurrent-session work can emit unrelated failures.
+- **Rely only on the inactivity watchdog.** Rejected because it delays a known
+  terminal failure and continues to describe a failed turn as running.
+- **Add a universal hard prompt timeout.** Rejected because quiet long-running
+  work is legitimate and silence is not terminal evidence.
+- **Wait only for an upstream OpenCode ACP fix.** Rejected as the sole remedy;
+  upstream conformance remains desirable, but Kandev can safely use diagnostics
+  from the subprocess it already supervises.
diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md
index 80581ce9b4..2a9eb76be7 100644
--- a/docs/decisions/INDEX.md
+++ b/docs/decisions/INDEX.md
@@ -104,3 +104,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
 | 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 |
 | 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 |
 | 2026-08-02-single-owner-agent-task-titles | [Assign Agent Task Titles to One Session](2026-08-02-single-owner-agent-task-titles.md) | accepted | backend, frontend, protocol, workflow | 2026-08-02 |
+| 2026-08-02-agent-terminal-diagnostics-over-stderr | [Capture Agent Terminal Diagnostics From Managed Stderr](2026-08-02-agent-terminal-diagnostics-over-stderr.md) | accepted | backend, frontend, protocol, security | 2026-08-02 |
diff --git a/docs/plans/opencode-terminal-error-surfacing/plan.md b/docs/plans/opencode-terminal-error-surfacing/plan.md
new file mode 100644
index 0000000000..9739b8a7c6
--- /dev/null
+++ b/docs/plans/opencode-terminal-error-surfacing/plan.md
@@ -0,0 +1,267 @@
+---
+spec: docs/specs/agent-stall-recovery/spec.md
+decision: docs/decisions/2026-08-02-agent-terminal-diagnostics-over-stderr.md
+created: 2026-08-02
+status: done
+---
+
+# Implementation Plan: OpenCode Terminal Error Surfacing
+
+## Overview
+
+Enable OpenCode's error-only stderr output, normalize only session-correlated
+foreground `stream error` records inside agentctl, and use that terminal signal
+to release a prompt whose ACP request never returns. Carry a bounded safe
+provider-error record through the existing lifecycle, classify usage-limit
+messages, and render a localized recovery card with collapsed technical details
+on desktop and mobile. Ambiguous silence continues to use the shipped advisory
+stall notice.
+
+---
+
+## Backend
+
+### Managed OpenCode diagnostic output
+
+- Change `OpenCodeACP.ManagedNPMRuntime` in
+  `apps/backend/internal/agent/agents/opencode_acp.go` so every managed OpenCode
+  ACP entry point uses `acp --print-logs --log-level ERROR`. Because normal
+  launch, capability probes, and one-shot inference all consume this shared
+  runtime spec, none may construct a divergent OpenCode command.
+- Update the command contracts in
+  `apps/backend/internal/agent/agents/opencode_acp_test.go`,
+  `apps/backend/internal/agent/agents/managed_npm_runtime_test.go`,
+  `apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go`, and
+  `apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md`.
+
+### Non-blocking stderr observation
+
+- Add an optional `StderrLineConsumer` interface beside `StderrProvider` in
+  `apps/backend/internal/agentctl/server/adapter/adapter.go`. The interface
+  receives an ANSI-stripped line as it is appended to the existing bounded
+  stderr ring; it is not a browser API and does not expose historical files.
+- Update `Manager.readStderr` in
+  `apps/backend/internal/agentctl/server/process/manager.go` to deliver each
+  cleaned line to an installed consumer without allowing parsing or channel
+  backpressure to block draining the child process. For OpenCode, apply a
+  protocol-specific safe projection before generic logging, the recent-stderr
+  ring, or process-exit events; malformed or unrelated records are excluded.
+  Other managed agents retain their existing stderr behavior.
+- Add focused process-manager regressions proving delivery, ANSI stripping,
+  non-consumer behavior, and that a nonzero OpenCode exit cannot expose raw
+  workspace URLs or identifiers through logs or exit events.
+
+### OpenCode diagnostic normalization and prompt correlation
+
+- Add `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go`
+  with a narrow key/value parser for OpenCode's emitted error records. Accept a
+  record only when `level=ERROR`, `message="stream error"`, `small` is not
+  `true`, `session.id` is present, and `error.error` is non-empty. Parse the
+  timestamp, provider ID, and model ID; strip URLs and identifier-bearing
+  suffixes from the error text before constructing the normalized record.
+- Add a protocol-neutral safe record in
+  `apps/backend/internal/agentctl/types/streams/provider_error.go` and attach it
+  optionally to `streams.AgentEvent`. Fields are limited to `source`,
+  `provider_id`, `model_id`, `message`, `occurred_at`, and `reset_at`.
+- Let the ACP adapter implement `StderrLineConsumer` only for
+  `agentID == "opencode-acp"`. Route a validated diagnostic to the current
+  prompt only when its provider session ID equals the adapter's active ACP
+  session and a foreground prompt turn is still pending. The delivery path must
+  be non-blocking and must discard stale lines after the prompt settles.
+- Extend the prompt-turn wait in
+  `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go`
+  so a correlated terminal diagnostic competes with the ACP RPC and user
+  cancellation. Whichever terminal path claims the prompt first cancels the
+  losing operation, preserves generation ownership, and emits one outcome.
+- Return a typed prompt error carrying the safe provider-error record. Update
+  `handleWSPrompt` and `Manager.SendErrorEvent` in
+  `apps/backend/internal/agentctl/server/api/agent.go` and
+  `apps/backend/internal/agentctl/server/process/manager.go` so the existing
+  error event includes those structured details. Do not send a second error if
+  the ACP RPC loses the race after the diagnostic has settled the prompt.
+
+### Lifecycle propagation and quota classification
+
+- Add the optional safe provider-error record to `AgentExecution`,
+  `AgentEventPayload`, and `watcher.AgentEventData` in
+  `apps/backend/internal/agent/runtime/lifecycle/types.go`,
+  `apps/backend/internal/agent/runtime/lifecycle/event_types.go`,
+  `apps/backend/internal/agent/runtime/lifecycle/events.go`, and
+  `apps/backend/internal/orchestrator/watcher/watcher.go`. Also add `AgentID` to
+  the lifecycle payload so downstream classification uses `opencode-acp`
+  rather than inferring an agent from prose.
+- In `manager_events.go`, copy structured details from the winning generation's
+  error event before `MarkCompleted` publishes `agent.failed`. Do not retain
+  details from a stale generation or a later duplicate terminal event.
+- Extend `apps/backend/internal/agent/runtime/routingerr/rules.go` so OpenCode's
+  exact `usage limit reached` signature classifies as high-confidence
+  `quota_limited`. Preserve the existing sanitizer and ensure account/workspace
+  URLs and long identifiers never enter `RawExcerpt`.
+- In `createRecoveryStatusMessage` in
+  `apps/backend/internal/orchestrator/event_handlers_agent.go`, classify the
+  failure using the event's `AgentID`, validate the structured provider record,
+  and persist the existing recovery actions with metadata:
+  `failure_kind: provider_quota_limited`, safe `provider_name`, optional
+  `model_id`, optional RFC3339 `reset_at`, and bounded sanitized `error_output`.
+  Fall back to the generic recoverable-error card when classification or
+  structured correlation is absent.
+- Continue using the current `MarkCompleted` -> `agent.failed` ->
+  `handleRecoverableFailure` path. Non-Office sessions become
+  `WAITING_FOR_INPUT`; Office sessions keep their existing `FAILED` behavior.
+  This feature does not invent a second lifecycle state or retry loop.
+
+---
+
+## Frontend
+
+### Localized provider-limit recovery
+
+- Extend `ActionMeta` / `MessageAction` metadata typing in
+  `apps/web/components/task/chat/types.ts` and
+  `apps/web/components/task/chat/messages/action-message.tsx` for the safe
+  provider-limit fields.
+- Add a `ProviderQuotaRecovery` branch beside `MissingBranchRecovery`. It uses
+  `failure_kind`, not string matching, to render a localized heading naming the
+  model when available, localized reset guidance when `reset_at` is present,
+  the existing recovery actions, and the existing collapsed **Technical
+  details** disclosure for sanitized `error_output`.
+- Add all new copy to `apps/web/src/locales/en/chat.json` and
+  `apps/web/src/locales/pseudo/chat.json`; resolve chat keys through
+  `useTranslation` at render time. Do not translate tokens that are compared
+  in control flow.
+- Keep the generic settled-error behavior unchanged for unknown provider
+  failures and keep the existing neutral running-stall notice unchanged.
+
+### Mobile design contract
+
+- **Desktop outcome:** the active spinner disappears when the correlated error
+  settles the turn. A compact error card identifies the affected model and
+  reset timing, keeps sanitized technical details collapsed, and exposes the
+  existing recovery actions.
+- **Mobile entry point and outcome:** the same card appears inline in the task
+  chat. No drawer or route is added; quota recovery is short, contextual
+  content already owned by the chat transcript.
+- **Nearest shipped exemplars:** `MissingBranchRecovery` supplies the localized
+  alert-card/details composition, while
+  `mobile-transient-retry.spec.ts` and
+  `mobile-pause-resume-recovery.spec.ts` supply the phone recovery-action and
+  geometry patterns.
+- **Hierarchy and geometry:** heading, reset guidance, collapsed details, then
+  recovery actions. Chat remains the single scroll owner; technical output has
+  its own bounded vertical overflow only when expanded. Mobile actions retain
+  the existing full-width 44px touch geometry, and the page gains no horizontal
+  overflow.
+- **Shared logic:** metadata interpretation, reset-time formatting, details,
+  and action handlers are shared across viewports; only responsive classes
+  control layout.
+
+---
+
+## Tests
+
+- **What:** every managed OpenCode ACP command enables error-only stderr while
+  other managed agents remain unchanged.
+  **Files:** `apps/backend/internal/agent/agents/opencode_acp_test.go`,
+  `managed_npm_runtime_test.go`, and lifecycle launch tests.
+  **How:** exact argv assertions across runtime, probe, and inference builders.
+- **What:** process stderr reaches an optional consumer as cleaned live lines
+  without changing the bounded recent-stderr contract.
+  **File:** `apps/backend/internal/agentctl/server/process/manager_test.go` or a
+  focused sibling test.
+  **How:** fixture process writes ANSI stderr; assertions cover ordered delivery,
+  no consumer, bounded ring behavior, a backlogged diagnostic channel that does
+  not block the drain loop, and a nonzero OpenCode exit whose log, ring, and
+  exit event contain only the safe projection.
+- **What:** only a main-session OpenCode stream error is normalized.
+  **File:** `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go`.
+  **How:** table tests cover the observed quota line, quoted fields, URL
+  sanitization, `small=true`, wrong level/message, missing fields, malformed
+  input, and unrelated session IDs.
+- **What:** a correlated diagnostic releases a held prompt exactly once while
+  stale/background/wrong-session diagnostics do not.
+  **File:** `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_test.go`.
+  **How:** fake ACP agent holds `session/prompt`; inject stderr through the
+  consumer and race it against RPC completion and cancellation.
+- **What:** structured provider details survive the lifecycle event boundary
+  only for the winning prompt generation.
+  **Files:** lifecycle `manager_events_test.go`, event publisher tests, and
+  orchestrator watcher tests.
+  **How:** direct error events with matching and stale generations.
+- **What:** `usage limit reached` becomes high-confidence `quota_limited`, raw
+  excerpts are sanitized, and recovery metadata contains only safe model/reset
+  fields plus existing actions.
+  **Files:** `routingerr/classify_test.go` and
+  `orchestrator/event_handlers_test.go` or a focused provider-failure test.
+  **How:** table classifier tests plus direct recoverable-failure handler tests.
+- **What:** provider quota metadata renders localized model/reset copy,
+  collapsed sanitized details, and 44px recovery actions without changing
+  generic errors or running stall notices.
+  **File:** `apps/web/components/task/chat/messages/action-message.test.tsx`.
+  **How:** store-backed rendered component tests using English and pseudo
+  locale fixtures.
+
+## E2E Tests
+
+- **Scenario:** given a settled OpenCode quota failure, opening the task shows
+  model/reset guidance rather than a running spinner; expanding technical
+  details reveals the sanitized message but no OpenCode workspace URL or ID.
+  **File:** `apps/web/e2e/tests/session/provider-quota-recovery.spec.ts`.
+- **Scenario:** the same provider-limit explanation, details disclosure, and
+  recovery actions are usable by touch with no horizontal overflow.
+  **File:** `apps/web/e2e/tests/session/mobile-provider-quota-recovery.spec.ts`.
+- Seed the settled session and persisted status-message metadata through the
+  existing E2E-only task/session/message helpers. Do not require a live quota
+  exhaustion or write provider credentials; the real stderr-to-error boundary
+  is covered by the agentctl and lifecycle integration tests.
+
+## Verification Results
+
+- Task 01: `cd apps/backend && go test ./internal/agent/agents ./internal/agentctl/server/process ./internal/agentctl/server/adapter/transport/acp -run 'Test(OpenCode|ManagedNPMRuntime|Manager.*Stderr|ParseOpenCode)' -count=1` — 24 tests passed.
+- Task 02: `cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api ./internal/agentctl/server/process -run 'Test(OpenCodeDiagnostic|Prompt.*Diagnostic|HandleWSPrompt.*ProviderError|SendErrorEvent)' -count=1` — 3 tests passed.
+- Task 03: `cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/agent/runtime/routingerr ./internal/orchestrator ./internal/orchestrator/watcher -run 'Test(.*ProviderError|.*OpenCode.*UsageLimit|HandleRecoverableFailure.*Quota|CreateRecoveryStatusMessage.*Quota)' -count=1` — 13 tests passed.
+- Task 04: `cd apps && pnpm --filter @kandev/web test -- --run components/task/chat/messages/action-message.test.tsx` — 17 tests passed; `cd apps/web && pnpm run typecheck`, `pnpm run i18n:check`, and `pnpm run i18n:ratchet` passed.
+- Task 05: managed desktop and mobile Playwright runs passed after the E2E seed was corrected to include `completed_at`: one Chromium test and one `mobile-chrome` test passed.
+- Fixup: `cd apps/backend && go test ./internal/agent/agents ./internal/agentctl/server/process ./internal/agentctl/server/adapter/transport/acp -run 'Test(OpenCode|ManagedNPMRuntime|Manager.*Stderr|ManagerProcessExit|ParseOpenCode)' -count=1` — 26 tests passed; `go test ./...` passed with 10,237 tests.
+
+## Implementation Waves And Parallel Candidates
+
+Execution is sequential in the primary conversation. Each task consumes a
+contract introduced by the previous task, so none is marked parallel-safe.
+
+Wave 1:
+
+- [x] [Task 01: Capture OpenCode error diagnostics](task-01-capture-opencode-error-diagnostics.md)
+
+Wave 2:
+
+- [x] [Task 02: Settle prompts from correlated diagnostics](task-02-settle-correlated-opencode-prompt.md)
+
+Wave 3:
+
+- [x] [Task 03: Classify and persist provider failures](task-03-classify-provider-failure.md)
+
+Wave 4:
+
+- [x] [Task 04: Render localized quota recovery](task-04-render-provider-quota-recovery.md)
+
+Wave 5:
+
+- [x] [Task 05: Prove desktop and mobile recovery](task-05-e2e-provider-quota-recovery.md)
+
+## Risks and non-goals
+
+- OpenCode's stderr record is an agent compatibility dialect. Parsing fails
+  closed: a format change loses the immediate error but cannot fail an
+  unrelated turn.
+- The stderr reader must never block, because blocking it can deadlock the
+  supervised process; the OpenCode diagnostic channel is bounded and its
+  consumer drops records when backlogged.
+- Prompt RPC completion, user cancellation, and the diagnostic may race; prompt
+  generation ownership and a single terminal claim are correctness boundaries.
+- Only the sanitized normalized provider record crosses agentctl. OpenCode raw
+  stderr is inspected in memory by its parser but is never written to generic
+  logs, the recent-stderr ring, process-exit events, persisted recovery
+  metadata, or browser payloads.
+- The plan does not read OpenCode log files, add an automatic model switch,
+  schedule reset-time retries, change Office routing, or replace the upstream
+  ACP fix.
diff --git a/docs/plans/opencode-terminal-error-surfacing/task-01-capture-opencode-error-diagnostics.md b/docs/plans/opencode-terminal-error-surfacing/task-01-capture-opencode-error-diagnostics.md
new file mode 100644
index 0000000000..8484638a01
--- /dev/null
+++ b/docs/plans/opencode-terminal-error-surfacing/task-01-capture-opencode-error-diagnostics.md
@@ -0,0 +1,90 @@
+---
+id: "01-capture-opencode-error-diagnostics"
+title: "Capture OpenCode error diagnostics"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/agent-stall-recovery/spec.md"
+---
+
+# Task 01: Capture OpenCode error diagnostics
+
+## Acceptance
+
+- Every managed OpenCode ACP command enables `--print-logs --log-level ERROR`,
+  while other managed agent commands remain unchanged.
+- Agentctl delivers cleaned live stderr lines to an optional consumer without
+  blocking child-process drainage or changing the bounded recent-stderr API.
+- For OpenCode, generic logs, recent-stderr, and nonzero-exit events contain
+  only the parser's safe provider-message projection; unrecognized records are
+  excluded so workspace URLs and identifiers cannot leak through the fallback
+  error path.
+- The OpenCode parser accepts only foreground `stream error` records and
+  returns allowlisted, URL-free provider/model/message/timestamp/reset fields;
+  background, malformed, and unrelated records are rejected.
+
+## Verification
+
+- `cd apps/backend && go test ./internal/agent/agents ./internal/agentctl/server/process ./internal/agentctl/server/adapter/transport/acp -run 'Test(OpenCode|ManagedNPMRuntime|Manager.*Stderr|ManagerProcessExit|ParseOpenCode)'`
+
+Use TDD: command-contract, stderr-delivery, and parser tests must fail before
+their production changes are added.
+
+## Files likely touched
+
+- `apps/backend/internal/agent/agents/opencode_acp.go`
+- `apps/backend/internal/agent/agents/opencode_acp_test.go`
+- `apps/backend/internal/agent/agents/managed_npm_runtime_test.go`
+- `apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md`
+- `apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go`
+- `apps/backend/internal/agentctl/server/adapter/adapter.go`
+- `apps/backend/internal/agentctl/server/process/manager.go`
+- `apps/backend/internal/agentctl/server/process/manager_test.go`
+- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go`
+- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go`
+- `apps/backend/internal/agentctl/types/streams/provider_error.go`
+- `apps/backend/internal/agentctl/types/streams/agent.go`
+
+## Dependencies
+
+None.
+
+## Parallelism
+
+Sequential. Task 02 consumes the parser, consumer, and safe provider-error
+contract introduced here.
+
+## Inputs
+
+- Spec sections `Correlated terminal diagnostics`, `Diagnostic contract`, and
+  diagnostic failure-mode scenarios
+- ADR-2026-08-02 stderr ownership, correlation, and sanitization rules
+- Existing `Manager.readStderr`, `StderrProvider`, `ManagedNPMRuntimeSpec`, and
+  ACP adapter construction patterns
+
+## Risks
+
+- `readStderr` is a process-liveness boundary; consumer delivery must remain
+  non-blocking, including when the bounded provider-diagnostic channel is full.
+- The parser must not preserve the OpenCode workspace URL or identifiers found
+  in the observed `error.error` suffix.
+- The generic process-exit path must consume only the safe OpenCode projection;
+  raw stderr must never appear in logs, the recent-stderr ring, or exit events.
+
+## Output contract
+
+Report RED assertions, exact argv, normalized field allowlist, rejected record
+cases, test results, files changed, blockers, and risks. Mark this task `done`
+and update its plan checkbox in the same conversation.
+
+## Results
+
+The managed command contract, optional stderr consumer, parser privacy,
+bounded-channel, and nonzero-exit projection cases all have focused
+regressions. OpenCode now uses `acp --print-logs --log-level ERROR`; the parser
+keeps only the validated foreground
+session/provider/model/message/timestamp/reset fields and rejects title,
+`small=true`, malformed, unrelated, or incomplete records. The focused
+verification passed with 26 tests across three packages; the full backend
+suite also passed.
diff --git a/docs/plans/opencode-terminal-error-surfacing/task-02-settle-correlated-opencode-prompt.md b/docs/plans/opencode-terminal-error-surfacing/task-02-settle-correlated-opencode-prompt.md
new file mode 100644
index 0000000000..0cadf4c277
--- /dev/null
+++ b/docs/plans/opencode-terminal-error-surfacing/task-02-settle-correlated-opencode-prompt.md
@@ -0,0 +1,79 @@
+---
+id: "02-settle-correlated-opencode-prompt"
+title: "Settle correlated OpenCode prompts"
+status: done
+wave: 2
+depends_on: ["01-capture-opencode-error-diagnostics"]
+plan: "plan.md"
+spec: "../../specs/agent-stall-recovery/spec.md"
+---
+
+# Task 02: Settle Prompts From Correlated Diagnostics
+
+## Acceptance
+
+- A validated OpenCode diagnostic settles only the pending foreground prompt
+  whose active ACP session ID matches the diagnostic.
+- Diagnostic, ACP response, and user-cancel races emit exactly one terminal
+  result for the owning prompt generation and release the losing operation.
+- The resulting agent error event carries the safe provider-error record;
+  wrong-session, background, stale, post-settlement, and non-OpenCode lines do
+  not affect the prompt.
+
+## Verification
+
+- `cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api ./internal/agentctl/server/process -run 'Test(OpenCodeDiagnostic|Prompt.*Diagnostic|HandleWSPrompt.*ProviderError|SendErrorEvent)'`
+
+Use a fake ACP agent that intentionally holds `session/prompt`; do not call a
+real provider or depend on wall-clock stall detection.
+
+## Files likely touched
+
+- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go`
+- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go`
+- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go`
+- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go`
+- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go`
+- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_test.go`
+- `apps/backend/internal/agentctl/server/api/agent.go`
+- `apps/backend/internal/agentctl/server/api/agent_test.go`
+- `apps/backend/internal/agentctl/server/process/manager.go`
+- `apps/backend/internal/agentctl/server/process/manager_test.go`
+- `apps/backend/internal/agentctl/types/streams/provider_error.go`
+
+## Dependencies
+
+Task 01.
+
+## Parallelism
+
+Sequential. Task 03 consumes the structured terminal event created here.
+
+## Inputs
+
+- Spec scenarios for active, wrong-session, background, stale, and racing
+  diagnostics
+- Plan section `OpenCode diagnostic normalization and prompt correlation`
+- Existing prompt-turn gate, generation claim, cancel acknowledgement, async
+  `handleWSPrompt`, and `SendErrorEvent` paths
+
+## Risks
+
+- A losing `conn.Prompt` goroutine must receive cancellation and cannot retain a
+  prompt gate, trace span, or later error delivery.
+- Stderr delivery cannot block on the prompt consumer channel.
+
+## Output contract
+
+Report RED race assertions, correlation rules, single-terminal proof, exact
+tests, files changed, blockers, and risks. Mark this task `done` and update its
+plan checkbox in the same conversation.
+
+## Results
+
+The ACP adapter correlates diagnostics to the active ACP session and prompt
+turn, cancels the held RPC through its context cause, and forwards one typed
+provider error through the existing async prompt event. Wrong-session and
+post-settlement diagnostics are ignored; duplicate lifecycle terminal events
+are guarded by prompt generation. The exact verification command passed with
+3 tests across three packages.
diff --git a/docs/plans/opencode-terminal-error-surfacing/task-03-classify-provider-failure.md b/docs/plans/opencode-terminal-error-surfacing/task-03-classify-provider-failure.md
new file mode 100644
index 0000000000..c768a5b1d8
--- /dev/null
+++ b/docs/plans/opencode-terminal-error-surfacing/task-03-classify-provider-failure.md
@@ -0,0 +1,82 @@
+---
+id: "03-classify-provider-failure"
+title: "Classify and persist provider failures"
+status: done
+wave: 3
+depends_on: ["02-settle-correlated-opencode-prompt"]
+plan: "plan.md"
+spec: "../../specs/agent-stall-recovery/spec.md"
+---
+
+# Task 03: Classify and Persist Provider Failures
+
+## Acceptance
+
+- The winning generation's safe provider-error record and concrete agent ID
+  reach `agent.failed`; stale or duplicate terminal events cannot replace them.
+- OpenCode `usage limit reached` classifies as high-confidence
+  `quota_limited`, with the existing sanitizer removing URLs and long
+  identifiers from every persisted excerpt.
+- Non-Office recovery messages persist `provider_quota_limited` metadata with
+  only safe provider/model/reset/details fields and existing recovery actions;
+  absent or unrecognized details use the generic error card.
+
+## Verification
+
+- `cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/agent/runtime/routingerr ./internal/orchestrator ./internal/orchestrator/watcher -run 'Test(.*ProviderError|.*OpenCode.*UsageLimit|HandleRecoverableFailure.*Quota|CreateRecoveryStatusMessage.*Quota)'`
+
+Use TDD at the lifecycle event, classifier, watcher, and persisted-message
+boundaries. Assert the observed OpenCode workspace URL and identifier are
+absent from error messages, raw excerpts, and message metadata.
+
+## Files likely touched
+
+- `apps/backend/internal/agent/runtime/lifecycle/types.go`
+- `apps/backend/internal/agent/runtime/lifecycle/event_types.go`
+- `apps/backend/internal/agent/runtime/lifecycle/events.go`
+- `apps/backend/internal/agent/runtime/lifecycle/manager_events.go`
+- `apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go`
+- `apps/backend/internal/agent/runtime/routingerr/rules.go`
+- `apps/backend/internal/agent/runtime/routingerr/classify_test.go`
+- `apps/backend/internal/orchestrator/watcher/watcher.go`
+- `apps/backend/internal/orchestrator/watcher/watcher_test.go`
+- `apps/backend/internal/orchestrator/event_handlers_agent.go`
+- `apps/backend/internal/orchestrator/event_handlers_test.go`
+
+## Dependencies
+
+Task 02.
+
+## Parallelism
+
+Sequential. Task 04 consumes the persisted metadata contract defined here.
+
+## Inputs
+
+- Spec `Diagnostic contract`, persistence guarantees, and quota scenarios
+- Plan sections `Lifecycle propagation and quota classification` and Tests
+- Existing `MarkCompleted`, `newAgentEventPayload`, `routingerr.Classify`,
+  `handleRecoverableFailure`, and `createRecoveryStatusMessage` paths
+
+## Risks
+
+- Provider classification must use the concrete `opencode-acp` agent ID and
+  must not infer trust from arbitrary error prose.
+- Raw recent stderr remains outside the persisted safe record.
+
+## Output contract
+
+Report RED assertions, event and metadata shapes, classification rule and
+confidence, sanitization proof, exact tests, files changed, blockers, and
+risks. Mark this task `done` and update its plan checkbox in the same
+conversation.
+
+## Results
+
+The safe provider record and concrete `opencode-acp` ID now reach
+`agent.failed`. The exact five-hour usage-limit signature is high-confidence
+`quota_limited`; URL/session/workspace identifiers are redacted before
+persistence. Non-Office recovery metadata includes only the specialized
+failure kind, provider/model/reset values, sanitized details, and existing
+actions. The exact verification command passed with 13 tests across four
+packages.
diff --git a/docs/plans/opencode-terminal-error-surfacing/task-04-render-provider-quota-recovery.md b/docs/plans/opencode-terminal-error-surfacing/task-04-render-provider-quota-recovery.md
new file mode 100644
index 0000000000..4ca2b571d7
--- /dev/null
+++ b/docs/plans/opencode-terminal-error-surfacing/task-04-render-provider-quota-recovery.md
@@ -0,0 +1,76 @@
+---
+id: "04-render-provider-quota-recovery"
+title: "Render localized quota recovery"
+status: done
+wave: 4
+depends_on: ["03-classify-provider-failure"]
+plan: "plan.md"
+spec: "../../specs/agent-stall-recovery/spec.md"
+---
+
+# Task 04: Render Localized Quota Recovery
+
+## Acceptance
+
+- `provider_quota_limited` metadata renders localized model/reset guidance,
+  existing recovery actions, and sanitized technical details collapsed by
+  default; no raw OpenCode URL or identifier is displayed.
+- Unknown provider failures retain the generic settled-error card, and running
+  inactivity notices retain their neutral compact behavior.
+- Desktop and phone use the same logic; phone actions remain at least 44px,
+  expanded details stay within the chat width, and no workflow depends on
+  hover.
+
+## Verification
+
+- `cd apps && pnpm install --frozen-lockfile`
+- `cd apps && pnpm --filter @kandev/web test -- --run components/task/chat/messages/action-message.test.tsx`
+- `cd apps/web && pnpm run typecheck`
+
+Use TDD for provider metadata, missing model/reset fallbacks, details
+disclosure, generic-error preservation, and pseudo-locale rendering.
+
+## Files likely touched
+
+- `apps/web/components/task/chat/types.ts`
+- `apps/web/components/task/chat/messages/action-message.tsx`
+- `apps/web/components/task/chat/messages/action-message.test.tsx`
+- `apps/web/src/locales/en/chat.json`
+- `apps/web/src/locales/pseudo/chat.json`
+
+## Dependencies
+
+Task 03.
+
+## Parallelism
+
+Sequential. Task 05 validates this presentation and its backend metadata.
+
+## Inputs
+
+- Spec provider-limit desktop/mobile scenarios
+- Plan `Localized provider-limit recovery` and mobile design contract
+- Existing `MissingBranchRecovery`, `TechnicalDetails`, `ActionButtons`, and
+  `useTranslation("chat")` patterns
+
+## Risks
+
+- Reset timestamps must be formatted at render time and must not call `t()` at
+  module scope.
+- The safe technical string is display-only; control flow must use
+  `failure_kind` rather than translated copy or substring matching.
+
+## Output contract
+
+Report RED assertions, translation keys, desktop/mobile hierarchy, technical
+details behavior, exact test/typecheck results, files changed, blockers, and
+risks. Mark this task `done` and update its plan checkbox in the same
+conversation.
+
+## Results
+
+Added the localized `ProviderQuotaRecovery` inline card with model/reset
+fallbacks, collapsed technical details, existing recovery actions, and shared
+desktop/mobile layout. Unknown failures and running stall notices retain their
+existing renderers. The focused component suite passed all 17 tests; web
+typecheck, i18n key/pseudo checks, and the new-code ratchet passed.
diff --git a/docs/plans/opencode-terminal-error-surfacing/task-05-e2e-provider-quota-recovery.md b/docs/plans/opencode-terminal-error-surfacing/task-05-e2e-provider-quota-recovery.md
new file mode 100644
index 0000000000..05d0440616
--- /dev/null
+++ b/docs/plans/opencode-terminal-error-surfacing/task-05-e2e-provider-quota-recovery.md
@@ -0,0 +1,78 @@
+---
+id: "05-e2e-provider-quota-recovery"
+title: "Prove desktop and mobile quota recovery"
+status: done
+wave: 5
+depends_on:
+  - "03-classify-provider-failure"
+  - "04-render-provider-quota-recovery"
+plan: "plan.md"
+spec: "../../specs/agent-stall-recovery/spec.md"
+---
+
+# Task 05: Prove Desktop and Mobile Recovery
+
+## Acceptance
+
+- Desktop Playwright proves a settled OpenCode quota failure replaces the
+  running status with model/reset guidance, keeps technical details collapsed,
+  and reveals no workspace URL or identifier when expanded.
+- Mobile Playwright proves the same information and recovery actions are
+  reachable by touch, actions are at least 44px, expanded details remain
+  viewport-contained, and the document has no horizontal overflow.
+- Tests use E2E-only session/message seeding and never exhaust real provider
+  quota, read a developer's OpenCode log, or add a production test hook.
+
+## Verification
+
+- `cd apps && pnpm install --frozen-lockfile`
+- `cd apps/web && pnpm e2e:run --no-build tests/session/provider-quota-recovery.spec.ts`
+- `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/session/mobile-provider-quota-recovery.spec.ts`
+
+Run against freshly built backend and Vite artifacts through the managed E2E
+runner. Inspect the rendered phone viewport in addition to assertions.
+
+## Files likely touched
+
+- `apps/web/e2e/pages/session-page.ts`
+- `apps/web/e2e/tests/session/provider-quota-recovery.spec.ts`
+- `apps/web/e2e/tests/session/mobile-provider-quota-recovery.spec.ts`
+
+## Dependencies
+
+Tasks 03 and 04.
+
+## Parallelism
+
+Sequential. This is the integrated user-visible proof for the completed
+backend and frontend contracts.
+
+## Inputs
+
+- Spec desktop/mobile quota scenarios and privacy guarantees
+- Plan E2E section and mobile design contract
+- Existing `seedTaskSession`, `seedSessionMessage`, missing-branch technical
+  details, transient-retry, and mobile pause/recovery patterns
+
+## Risks
+
+- Seed only the safe normalized metadata contract; do not copy the real
+  OpenCode workspace URL or account identifier into fixtures.
+- A passing seeded UI test does not replace Task 02's real prompt-boundary
+  integration regression.
+
+## Output contract
+
+Report RED failures, exact desktop/mobile results, rendered mobile inspection,
+privacy assertions, files changed, blockers, risks, and teardown evidence.
+Mark this task `done`, update its plan checkbox, and synchronize the plan's
+verification results in the same conversation.
+
+## Results
+
+Added E2E-only seeded desktop and mobile specs. Both managed production-build
+runs passed: Chromium verifies the localized card, collapsed/expanded privacy
+details, and recovery actions; `mobile-chrome` verifies touch disclosure,
+44px actions, viewport containment, and no horizontal overflow. The first run
+found and fixed a fixture requirement for `completed_at`; no provider quota or
+developer log was used.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index eac62db2c4..a7ad5f6a91 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -179,7 +179,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
 | Spec | Status |
 |---|---|
 | [agent-resume-runtime-recovery](agent-resume-runtime-recovery/spec.md) | shipped |
-| [agent-stall-recovery](agent-stall-recovery/spec.md) | draft |
+| [agent-stall-recovery](agent-stall-recovery/spec.md) | approved |
 | [mcp-session-observability](mcp-session-observability/spec.md) | approved |
 | [auth](auth/spec.md) | building |
 | [create-local-repository](create-local-repository/spec.md) | shipped |
diff --git a/docs/specs/agent-stall-recovery/spec.md b/docs/specs/agent-stall-recovery/spec.md
index 0f54cffe96..5d236a537a 100644
--- a/docs/specs/agent-stall-recovery/spec.md
+++ b/docs/specs/agent-stall-recovery/spec.md
@@ -1,31 +1,34 @@
 ---
-status: draft
+status: approved
 created: 2026-07-29
+updated: 2026-08-02
 owner: Kandev
 ---
 
 # Agent Stall Recovery
 
-Decision:
-[ADR-2026-07-29-agent-stall-user-controlled-recovery](../../decisions/2026-07-29-agent-stall-user-controlled-recovery.md)
+Decisions:
+
+- [ADR-2026-07-29-agent-stall-user-controlled-recovery](../../decisions/2026-07-29-agent-stall-user-controlled-recovery.md)
+- [ADR-2026-08-02-agent-terminal-diagnostics-over-stderr](../../decisions/2026-08-02-agent-terminal-diagnostics-over-stderr.md)
+
+Implementation plans:
+
+- [Agent stall recovery](../../plans/agent-stall-recovery/plan.md)
+- [OpenCode terminal error surfacing](../../plans/opencode-terminal-error-surfacing/plan.md)
 
 ## Why
 
-An agent turn can remain `RUNNING` forever after its active tool stops emitting
-events. Users currently see only “Agent is running,” while Resume is correctly
-blocked by the active-session guard and the backend repeats a diagnostic that
-the UI never receives.
-
-## Broken behavior
-
-`waitForPromptDone` detects five minutes without events but only logs the
-condition every 30 seconds. A top-level shell tool that remains `in_progress`
-can therefore hold the prompt waiter and session state indefinitely even
-though Kandev already has a bounded cancel-escalation path capable of releasing
-the turn.
+An agent turn can remain `RUNNING` after it stops emitting events. Silence alone
+is ambiguous because a legitimate long-running tool may also be quiet, but some
+agents know that their provider stream has failed and fail to close the ACP
+prompt. Users need Kandev to distinguish trustworthy terminal evidence from
+mere inactivity so a failed turn is not presented as healthy work.
 
 ## What
 
+### Advisory inactivity recovery
+
 - Kandev checks running prompts for inactivity once per 60 seconds.
 - After five minutes without agent events, Kandev creates at most one
   user-visible notice for that prompt generation.
@@ -46,16 +49,80 @@ the turn.
 - The backend logs the first stall detected for a prompt generation and does
   not emit another notice or log entry on every watchdog check.
 
+### Correlated terminal diagnostics
+
+- When an agent emits a terminal provider diagnostic outside ACP, Kandev acts
+  on it only when the diagnostic identifies the current agent type, active
+  provider session, and active foreground prompt.
+- OpenCode provider diagnostics are collected from the managed `opencode acp`
+  process's error-only stderr stream. Kandev does not read OpenCode's private
+  files under `~/.local/share/opencode/log`.
+- A correlated OpenCode `stream error` ends the affected prompt through the
+  existing agent-error lifecycle instead of waiting for the inactivity
+  threshold. The session stops presenting itself as `RUNNING` and retains the
+  existing recovery actions.
+- Kandev preserves only allowlisted diagnostic fields needed for recovery:
+  source agent, provider, model, sanitized message, occurrence time, and a
+  reset time when one can be derived. Raw log lines, workspace URLs, account or
+  workspace identifiers, paths, credentials, and unrelated stderr are not
+  persisted or sent to the browser.
+- A recognized OpenCode usage-limit message is classified as
+  `quota_limited`. Its recovery surface names the affected model when known,
+  explains when capacity resets, and keeps sanitized technical details
+  collapsed by default.
+- The user-facing provider-limit copy is localized. Desktop and phone layouts
+  expose the same failure explanation, technical details, and recovery actions.
+- A diagnostic for a background/title request, another OpenCode session, an
+  earlier prompt generation, or a prompt that has already settled cannot fail
+  the active turn.
+- An unrecognized, malformed, or unavailable diagnostic stream does not become
+  proof of failure. The prompt continues and the advisory inactivity recovery
+  remains available.
+
+## Diagnostic contract
+
+The internal normalized provider diagnostic contains only:
+
+| Field | Meaning |
+| --- | --- |
+| `source` | Stable diagnostic source identifier, initially `opencode_stderr` |
+| `provider_id` | Provider identifier reported by the agent, when present |
+| `model_id` | Model identifier reported by the agent, when present |
+| `message` | Sanitized provider message with URLs and identifiers removed |
+| `occurred_at` | Parsed diagnostic timestamp |
+| `reset_at` | Derived by adding a relative reset duration to `occurred_at`; absolute reset timestamps are not accepted in this version |
+
+The persisted recovery-message metadata uses
+`failure_kind: provider_quota_limited`, plus the safe model and reset fields,
+to select localized presentation. `error_output` may contain the same bounded,
+sanitized diagnostic message for the collapsed technical-details surface.
+
 ## Failure modes
 
-- If active tool identity is unavailable, the generic notice and cancel action
-  still appear.
+- If active tool identity is unavailable, the generic inactivity notice and
+  cancel action still appear.
 - If the agent acknowledges cancellation, normal cancellation reconciliation
   makes the session input-ready.
 - If the agent does not acknowledge cancellation, the existing bounded
   cancel-escalation path releases the prompt and reconciles the session.
 - If notice-message persistence fails, the failure is logged without changing
   or terminating the running session.
+- If OpenCode does not support error-only stderr emission, changes its log
+  format, or emits a line that fails validation, Kandev ignores the line and
+  retains normal ACP plus inactivity behavior.
+- If a validated terminal diagnostic races with an ACP prompt response, only
+  one terminal event settles the prompt generation.
+- If sanitization removes all usable provider text, Kandev surfaces a generic
+  OpenCode provider failure without exposing the raw diagnostic.
+
+## Persistence guarantees
+
+- Advisory stall notices retain their existing persisted-message behavior.
+- Sanitized provider failures use the existing session error and recovery
+  message persistence. They survive reloads like other recoverable failures.
+- Raw agent stderr remains a bounded, executor-local in-memory diagnostic. It
+  is not added to session metadata, messages, debug exports, or durable logs by
+  this feature.
 
 ## Scenarios
 
@@ -81,13 +148,37 @@ the turn.
 - **GIVEN** a quiet but legitimate long-running turn, **WHEN** the inactivity
   threshold passes and the user does not cancel, **THEN** Kandev leaves the
   turn and process running.
+- **GIVEN** the active OpenCode prompt emits a `stream error` for its own ACP
+  session, **WHEN** the diagnostic says the model's five-hour usage limit was
+  reached, **THEN** Kandev ends the running state and shows a localized
+  quota-limit recovery message with the model and reset time.
+- **GIVEN** that quota-limit recovery is shown, **WHEN** the user expands
+  technical details, **THEN** the sanitized provider message is visible and no
+  OpenCode workspace URL or identifier is present.
+- **GIVEN** OpenCode emits a title-generation error or an error for another
+  session, **WHEN** a foreground prompt is active, **THEN** Kandev ignores that
+  diagnostic and does not settle the foreground prompt.
+- **GIVEN** a correlated diagnostic and the ACP response arrive concurrently,
+  **WHEN** the turn settles, **THEN** exactly one terminal outcome is emitted
+  for that prompt generation.
+- **GIVEN** an OpenCode build without usable diagnostic stderr, **WHEN** its ACP
+  prompt hangs without further events, **THEN** the five-minute advisory notice
+  remains the recovery path.
+- **GIVEN** the provider-limit recovery renders on a phone viewport, **WHEN**
+  the user reads details or selects a recovery action, **THEN** the same outcome
+  is available through touch targets of at least 44px with no horizontal page
+  overflow.
 
 ## Out of scope
 
-- Automatically timing out, failing, cancelling, or killing quiet turns.
+- Automatically timing out, failing, cancelling, or killing a turn based only
+  on inactivity.
 - Making the inactivity threshold user-configurable.
-- Provider-specific repair of shell commands that hold child-process streams
-  open.
-- Treating event silence as proof that an agent process crashed.
-- Allowing Resume or direct prompt admission while the session remains
-  `RUNNING`.
+- Reading, tailing, or exposing an agent vendor's private log files.
+- Treating arbitrary stderr text as trusted terminal evidence.
+- Persisting raw stderr or raw OpenCode log records.
+- Following or exposing OpenCode account/balance URLs from provider errors.
+- Automatically purchasing capacity, changing models, or scheduling a retry at
+  the reset time.
+- Repairing OpenCode's ACP implementation upstream; OpenCode should still be
+  encouraged to return the provider failure directly from `session/prompt`.