diff --git a/AGENTS.md b/AGENTS.md
index f91a5fd680..625eae239e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -172,6 +172,18 @@ JSON-RPC 2.0 over stdin/stdout between agentctl and agent process.
## Frontend Architecture
+### UI Components
+
+**Shadcn Components:** Import from `@kandev/ui` package:
+```typescript
+import { Badge } from '@kandev/ui/badge';
+import { Button } from '@kandev/ui/button';
+import { Dialog } from '@kandev/ui/dialog';
+// etc...
+```
+
+**Do NOT** import from `@/components/ui/*` - always use `@kandev/ui` package.
+
### Data Flow Pattern (Critical)
```
@@ -222,6 +234,7 @@ lib/api/domains/{kanban,session,workspace,settings,process}-api.ts # API client
### Frontend
- **Data:** SSR fetch → hydrate → read store. Never fetch in components
+- **UI Components:** Import shadcn components from `@kandev/ui`, NOT `@/components/ui/*`
- **Components:** <200 lines, extract to domain components, composition over props
- **Hooks:** Domain-organized in `hooks/domains/`, encapsulate subscription + selection
- **WS:** Use subscription hooks only; client auto-deduplicates
diff --git a/apps/backend/cmd/kandev/worktree.go b/apps/backend/cmd/kandev/worktree.go
index a42fc43848..3096d91629 100644
--- a/apps/backend/cmd/kandev/worktree.go
+++ b/apps/backend/cmd/kandev/worktree.go
@@ -1,15 +1,42 @@
package main
import (
+ "context"
"database/sql"
"github.com/kandev/kandev/internal/agent/lifecycle"
- "github.com/kandev/kandev/internal/worktree"
"github.com/kandev/kandev/internal/common/config"
+ "github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
+ "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
+ "github.com/kandev/kandev/internal/worktree"
)
+// taskServiceAdapter adapts the task service to the worktree.TaskService interface.
+type taskServiceAdapter struct {
+ svc *taskservice.Service
+}
+
+func (a *taskServiceAdapter) CreateMessage(ctx context.Context, req *worktree.CreateMessageRequest) (*models.Message, error) {
+ // Convert worktree.CreateMessageRequest to taskservice.CreateMessageRequest
+ return a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
+ TaskSessionID: req.TaskSessionID,
+ TaskID: req.TaskID,
+ TurnID: req.TurnID,
+ Content: req.Content,
+ AuthorType: req.AuthorType,
+ AuthorID: req.AuthorID,
+ RequestsInput: req.RequestsInput,
+ Type: req.Type,
+ Metadata: req.Metadata,
+ })
+}
+
+func (a *taskServiceAdapter) UpdateMessage(ctx context.Context, message *models.Message) error {
+ return a.svc.UpdateMessage(ctx, message)
+}
+
func provideWorktreeManager(dbConn *sql.DB, cfg *config.Config, log *logger.Logger, lifecycleMgr *lifecycle.Manager, taskSvc *taskservice.Service) (*worktree.Manager, func() error, error) {
manager, cleanup, err := worktree.Provide(dbConn, cfg, log)
if err != nil {
@@ -19,5 +46,19 @@ func provideWorktreeManager(dbConn *sql.DB, cfg *config.Config, log *logger.Logg
lifecycleMgr.SetWorktreeManager(manager)
}
taskSvc.SetWorktreeCleanup(manager)
+
+ // Wire script message handler with adapters
+ taskSvcAdapter := &taskServiceAdapter{svc: taskSvc}
+
+ scriptHandler := worktree.NewDefaultScriptMessageHandler(
+ log,
+ taskSvcAdapter,
+ constants.SetupScriptTimeout,
+ )
+ repoAdapter := worktree.NewRepositoryAdapter(taskSvc)
+
+ manager.SetScriptMessageHandler(scriptHandler)
+ manager.SetRepositoryProvider(repoAdapter)
+
return manager, cleanup, nil
}
diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go
index 6ce5e1f581..76431f74e3 100644
--- a/apps/backend/internal/agent/lifecycle/manager.go
+++ b/apps/backend/internal/agent/lifecycle/manager.go
@@ -995,9 +995,12 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
// Flush any accumulated content and clear streaming state on error
m.flushMessageBuffer(execution)
+ // Log all available error information for debugging
m.logger.Error("agent error",
zap.String("execution_id", execution.ID),
- zap.String("error", event.Error))
+ zap.String("error", event.Error),
+ zap.String("text", event.Text),
+ zap.Any("data", event.Data))
case "complete":
m.logger.Info("agent turn complete",
diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go
index cd93662dcc..886d8e2168 100644
--- a/apps/backend/internal/agentctl/server/adapter/adapter.go
+++ b/apps/backend/internal/agentctl/server/adapter/adapter.go
@@ -39,6 +39,14 @@ const (
EventTypeContextWindow = streams.EventTypeContextWindow
)
+// StderrProvider provides access to recent stderr output for error context.
+// This is used by adapters to include stderr in error events when the agent
+// reports an error without a detailed message (e.g., rate limit errors).
+type StderrProvider interface {
+ // GetRecentStderr returns the most recent stderr lines from the agent process.
+ GetRecentStderr() []string
+}
+
// AgentInfo contains information about the connected agent.
type AgentInfo struct {
Name string `json:"name"`
diff --git a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go
index a120b6487c..7e3f582b21 100644
--- a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go
+++ b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go
@@ -47,6 +47,9 @@ type CodexAdapter struct {
// Permission handler
permissionHandler PermissionHandler
+ // Stderr provider for error context
+ stderrProvider StderrProvider
+
// Accumulators for streaming content
messageBuffer string
reasoningBuffer string
@@ -541,6 +544,15 @@ func (a *CodexAdapter) SetPermissionHandler(handler PermissionHandler) {
a.permissionHandler = handler
}
+// SetStderrProvider sets the provider for recent stderr output.
+// This is used to include stderr in error events when the agent
+// reports an error without a detailed message.
+func (a *CodexAdapter) SetStderrProvider(provider StderrProvider) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ a.stderrProvider = provider
+}
+
// Close releases resources held by the adapter.
func (a *CodexAdapter) Close() error {
a.mu.Lock()
@@ -656,14 +668,26 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
}
}
- // Send error event if the turn failed (for UI notification)
- if !p.Success && p.Error != "" {
- a.sendUpdate(AgentEvent{
- Type: EventTypeError,
- SessionID: threadID,
- OperationID: p.TurnID,
- Error: p.Error,
- })
+ // Send error event if the turn failed WITH an explicit error message.
+ // Note: We don't send error events here based on stderr alone, because
+ // NotifyError will handle error notifications (prevents duplicate messages).
+ if !p.Success {
+ a.logger.Debug("turn completed with failure",
+ zap.String("thread_id", threadID),
+ zap.String("turn_id", p.TurnID),
+ zap.Bool("success", p.Success),
+ zap.String("error", p.Error))
+
+ // Only send error event if there's an explicit error message
+ // (NotifyError handles the case when error details come separately)
+ if p.Error != "" {
+ a.sendUpdate(AgentEvent{
+ Type: EventTypeError,
+ SessionID: threadID,
+ OperationID: p.TurnID,
+ Error: p.Error,
+ })
+ }
}
case codex.NotifyTurnDiffUpdated:
@@ -705,10 +729,57 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
a.logger.Warn("failed to parse error notification", zap.Error(err))
return
}
+
+ // Get recent stderr for error context
+ var stderrLines []string
+ a.mu.RLock()
+ if a.stderrProvider != nil {
+ stderrLines = a.stderrProvider.GetRecentStderr()
+ }
+ a.mu.RUnlock()
+
+ // Try to parse stderr into structured error info
+ // This handles Codex-specific error formats (e.g., rate limits)
+ parsedError := parseCodexStderrLines(stderrLines)
+
+ var parsedMsg string
+ if parsedError != nil {
+ parsedMsg = parsedError.Message
+ }
+
+ a.logger.Debug("received error notification from agent",
+ zap.String("thread_id", threadID),
+ zap.Int("code", p.Code),
+ zap.String("message", p.Message),
+ zap.String("parsed_message", parsedMsg),
+ zap.Any("data", p.Data),
+ zap.Int("stderr_lines", len(stderrLines)))
+
+ // Build error data with all available context
+ errorData := map[string]interface{}{
+ "code": p.Code,
+ "data": p.Data,
+ "stderr": stderrLines,
+ }
+
+ // Include parsed error details if available
+ if parsedError != nil {
+ parsed := map[string]any{
+ "http_error": parsedError.HTTPError,
+ }
+ // Include the full raw JSON (captures all fields from any error type)
+ if parsedError.RawJSON != nil {
+ parsed["error_json"] = parsedError.RawJSON
+ }
+ errorData["parsed"] = parsed
+ }
+
a.sendUpdate(AgentEvent{
Type: EventTypeError,
SessionID: threadID,
Error: p.Message,
+ Text: parsedMsg, // User-friendly parsed message
+ Data: errorData,
})
case codex.NotifyItemStarted:
diff --git a/apps/backend/internal/agentctl/server/adapter/codex_errors.go b/apps/backend/internal/agentctl/server/adapter/codex_errors.go
new file mode 100644
index 0000000000..291f8f1bda
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/adapter/codex_errors.go
@@ -0,0 +1,135 @@
+package adapter
+
+import (
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// codexErrorRegex matches Codex error log format:
+// TIMESTAMP ERROR module: error=HTTP_ERROR: Some("JSON")
+var codexErrorRegex = regexp.MustCompile(`error=(.+?):\s*Some\("(.+)"\)\s*$`)
+
+// CodexParsedError contains the parsed error information from Codex stderr.
+type CodexParsedError struct {
+ // Message is the user-friendly error message
+ Message string
+ // HTTPError is the HTTP error string (e.g., "http 429 Too Many Requests")
+ HTTPError string
+ // RawJSON contains all fields from the error JSON (generic, captures any structure)
+ RawJSON map[string]any
+ // ErrorType is the error type from the JSON (e.g., "usage_limit_reached") - may be empty
+ ErrorType string
+ // ResetsInSeconds is the number of seconds until the limit resets - may be 0
+ ResetsInSeconds int64
+}
+
+// parseCodexStderrError attempts to parse a Codex stderr error line and extract
+// error information. Returns nil if parsing fails.
+//
+// Example input:
+//
+// 2026-01-23T22:57:08.953223Z ERROR codex_api::endpoint::responses: error=http 429 Too Many Requests: Some("{\"error\":{...}}")
+func parseCodexStderrError(line string) *CodexParsedError {
+ matches := codexErrorRegex.FindStringSubmatch(line)
+ if len(matches) < 3 {
+ return nil
+ }
+
+ httpError := strings.TrimSpace(matches[1])
+ jsonStr := matches[2]
+
+ // Unescape the JSON string (it's double-escaped in the log)
+ unescaped := strings.ReplaceAll(jsonStr, `\"`, `"`)
+ unescaped = strings.ReplaceAll(unescaped, `\\`, `\`)
+
+ result := &CodexParsedError{
+ HTTPError: httpError,
+ }
+
+ // Try to parse the JSON into a generic map first (captures all fields)
+ var rawData map[string]any
+ if err := json.Unmarshal([]byte(unescaped), &rawData); err != nil {
+ // Couldn't parse JSON at all, just return HTTP error
+ result.Message = httpError
+ return result
+ }
+
+ result.RawJSON = rawData
+
+ // Extract common fields - check both nested "error" object and top-level fields
+ var errorType, errorMessage string
+ var resetsInSeconds int64
+
+ // First, check for nested error object (standard format)
+ if errObj, ok := rawData["error"].(map[string]any); ok {
+ if t, ok := errObj["type"].(string); ok {
+ errorType = t
+ }
+ if m, ok := errObj["message"].(string); ok {
+ errorMessage = m
+ }
+ // Handle both int and float64 (JSON numbers are float64 in Go)
+ if r, ok := errObj["resets_in_seconds"].(float64); ok {
+ resetsInSeconds = int64(r)
+ }
+ }
+
+ // Fall back to top-level fields if nested fields not found (flat error format)
+ if errorMessage == "" {
+ if m, ok := rawData["message"].(string); ok {
+ errorMessage = m
+ }
+ }
+ if errorType == "" {
+ if t, ok := rawData["type"].(string); ok {
+ errorType = t
+ }
+ }
+
+ result.ErrorType = errorType
+ result.ResetsInSeconds = resetsInSeconds
+
+ // Build a user-friendly message from available fields
+ var msg string
+
+ if errorMessage != "" {
+ // We have a specific error message from the API
+ msg = errorMessage
+ // Add reset time info if available
+ if resetsInSeconds > 0 {
+ duration := time.Duration(resetsInSeconds) * time.Second
+ if duration.Hours() >= 1 {
+ msg = fmt.Sprintf("%s (resets in %.0f hours)", msg, duration.Hours())
+ } else if duration.Minutes() >= 1 {
+ msg = fmt.Sprintf("%s (resets in %.0f minutes)", msg, duration.Minutes())
+ } else {
+ msg = fmt.Sprintf("%s (resets in %d seconds)", msg, int(duration.Seconds()))
+ }
+ }
+ } else if errorType != "" {
+ // We have an error type but no message
+ msg = fmt.Sprintf("Error: %s", errorType)
+ } else {
+ // Unknown error format - show HTTP error and include JSON body
+ jsonBytes, _ := json.MarshalIndent(rawData, "", " ")
+ msg = fmt.Sprintf("%s\n\n%s", httpError, string(jsonBytes))
+ }
+
+ result.Message = msg
+ return result
+}
+
+// parseCodexStderrLines attempts to parse Codex stderr lines and extract
+// error information. Searches from the end (most recent) first.
+// Returns nil if no parseable error is found.
+func parseCodexStderrLines(lines []string) *CodexParsedError {
+ for i := len(lines) - 1; i >= 0; i-- {
+ if parsed := parseCodexStderrError(lines[i]); parsed != nil {
+ return parsed
+ }
+ }
+ return nil
+}
diff --git a/apps/backend/internal/agentctl/server/adapter/codex_errors_test.go b/apps/backend/internal/agentctl/server/adapter/codex_errors_test.go
new file mode 100644
index 0000000000..44491951b1
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/adapter/codex_errors_test.go
@@ -0,0 +1,245 @@
+package adapter
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseCodexStderrError(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantNil bool
+ wantHTTPError string
+ wantErrorType string
+ wantMsgContains string
+ wantRawJSON bool
+ }{
+ {
+ name: "no match - empty string",
+ input: "",
+ wantNil: true,
+ },
+ {
+ name: "no match - regular log line",
+ input: "2026-01-23T22:57:08.953223Z INFO some_module: doing something",
+ wantNil: true,
+ },
+ {
+ name: "no match - error without Some pattern",
+ input: "2026-01-23T22:57:08.953223Z ERROR some_module: error=something went wrong",
+ wantNil: true,
+ },
+ {
+ name: "rate limit error - full format",
+ input: `2026-01-23T22:57:08.953223Z ERROR codex_api::endpoint::responses: error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"The usage limit has been reached\",\"resets_in_seconds\":57600}}")`,
+ wantNil: false,
+ wantHTTPError: "http 429 Too Many Requests",
+ wantErrorType: "usage_limit_reached",
+ wantMsgContains: "The usage limit has been reached",
+ wantRawJSON: true,
+ },
+ {
+ name: "rate limit error - with reset time in hours",
+ input: `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"Limit reached\",\"resets_in_seconds\":7200}}")`,
+ wantNil: false,
+ wantHTTPError: "http 429 Too Many Requests",
+ wantErrorType: "usage_limit_reached",
+ wantMsgContains: "resets in 2 hours",
+ wantRawJSON: true,
+ },
+ {
+ name: "rate limit error - with reset time in minutes",
+ input: `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"Limit reached\",\"resets_in_seconds\":300}}")`,
+ wantNil: false,
+ wantHTTPError: "http 429 Too Many Requests",
+ wantMsgContains: "resets in 5 minutes",
+ wantRawJSON: true,
+ },
+ {
+ name: "rate limit error - with reset time in seconds",
+ input: `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"Limit reached\",\"resets_in_seconds\":45}}")`,
+ wantNil: false,
+ wantHTTPError: "http 429 Too Many Requests",
+ wantMsgContains: "resets in 45 seconds",
+ wantRawJSON: true,
+ },
+ {
+ name: "auth error",
+ input: `error=http 401 Unauthorized: Some("{\"error\":{\"type\":\"invalid_api_key\",\"message\":\"Invalid API key provided\"}}")`,
+ wantNil: false,
+ wantHTTPError: "http 401 Unauthorized",
+ wantErrorType: "invalid_api_key",
+ wantMsgContains: "Invalid API key provided",
+ wantRawJSON: true,
+ },
+ {
+ name: "error with type but no message",
+ input: `error=http 400 Bad Request: Some("{\"error\":{\"type\":\"invalid_request\"}}")`,
+ wantNil: false,
+ wantHTTPError: "http 400 Bad Request",
+ wantErrorType: "invalid_request",
+ wantMsgContains: "Error: invalid_request",
+ wantRawJSON: true,
+ },
+ {
+ name: "flat error structure - message at top level",
+ input: `error=http 500 Internal Server Error: Some("{\"message\":\"Something went wrong\",\"code\":500}")`,
+ wantNil: false,
+ wantHTTPError: "http 500 Internal Server Error",
+ wantMsgContains: "Something went wrong",
+ wantRawJSON: true,
+ },
+ {
+ name: "unknown JSON structure",
+ input: `error=http 502 Bad Gateway: Some("{\"status\":\"error\",\"details\":{\"reason\":\"upstream timeout\"}}")`,
+ wantNil: false,
+ wantHTTPError: "http 502 Bad Gateway",
+ wantMsgContains: "http 502 Bad Gateway",
+ wantRawJSON: true,
+ },
+ {
+ name: "invalid JSON - still returns HTTP error",
+ input: `error=http 500 Internal Server Error: Some("not valid json")`,
+ wantNil: false,
+ wantHTTPError: "http 500 Internal Server Error",
+ wantMsgContains: "http 500 Internal Server Error",
+ wantRawJSON: false,
+ },
+ {
+ name: "empty JSON object",
+ input: `error=http 500 Internal Server Error: Some("{}")`,
+ wantNil: false,
+ wantHTTPError: "http 500 Internal Server Error",
+ wantMsgContains: "http 500 Internal Server Error",
+ wantRawJSON: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parseCodexStderrError(tt.input)
+
+ if tt.wantNil {
+ if result != nil {
+ t.Errorf("expected nil, got %+v", result)
+ }
+ return
+ }
+
+ if result == nil {
+ t.Fatal("expected non-nil result, got nil")
+ }
+
+ if result.HTTPError != tt.wantHTTPError {
+ t.Errorf("HTTPError = %q, want %q", result.HTTPError, tt.wantHTTPError)
+ }
+
+ if tt.wantErrorType != "" && result.ErrorType != tt.wantErrorType {
+ t.Errorf("ErrorType = %q, want %q", result.ErrorType, tt.wantErrorType)
+ }
+
+ if !strings.Contains(result.Message, tt.wantMsgContains) {
+ t.Errorf("Message = %q, want to contain %q", result.Message, tt.wantMsgContains)
+ }
+
+ if tt.wantRawJSON && result.RawJSON == nil {
+ t.Error("expected RawJSON to be non-nil")
+ }
+ if !tt.wantRawJSON && result.RawJSON != nil {
+ t.Error("expected RawJSON to be nil")
+ }
+ })
+ }
+}
+
+func TestParseCodexStderrLines(t *testing.T) {
+ tests := []struct {
+ name string
+ lines []string
+ wantNil bool
+ wantMsgContains string
+ }{
+ {
+ name: "empty lines",
+ lines: []string{},
+ wantNil: true,
+ },
+ {
+ name: "nil lines",
+ lines: nil,
+ wantNil: true,
+ },
+ {
+ name: "no error lines",
+ lines: []string{
+ "2026-01-23T22:57:08Z INFO starting up",
+ "2026-01-23T22:57:09Z DEBUG processing request",
+ },
+ wantNil: true,
+ },
+ {
+ name: "error at end - returns most recent",
+ lines: []string{
+ "2026-01-23T22:57:08Z INFO starting up",
+ `error=http 429 Too Many Requests: Some("{\"error\":{\"message\":\"Rate limited\"}}")`,
+ },
+ wantNil: false,
+ wantMsgContains: "Rate limited",
+ },
+ {
+ name: "error in middle - returns it",
+ lines: []string{
+ `error=http 500 Server Error: Some("{\"error\":{\"message\":\"Server error\"}}")`,
+ "2026-01-23T22:57:09Z INFO recovered",
+ },
+ wantNil: false,
+ wantMsgContains: "Server error",
+ },
+ {
+ name: "multiple errors - returns most recent (last)",
+ lines: []string{
+ `error=http 500 Server Error: Some("{\"error\":{\"message\":\"First error\"}}")`,
+ "2026-01-23T22:57:09Z INFO retrying",
+ `error=http 429 Rate Limited: Some("{\"error\":{\"message\":\"Second error\"}}")`,
+ },
+ wantNil: false,
+ wantMsgContains: "Second error",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parseCodexStderrLines(tt.lines)
+
+ if tt.wantNil {
+ if result != nil {
+ t.Errorf("expected nil, got %+v", result)
+ }
+ return
+ }
+
+ if result == nil {
+ t.Fatal("expected non-nil result, got nil")
+ }
+
+ if !strings.Contains(result.Message, tt.wantMsgContains) {
+ t.Errorf("Message = %q, want to contain %q", result.Message, tt.wantMsgContains)
+ }
+ })
+ }
+}
+
+func TestParseCodexStderrError_ResetsInSeconds(t *testing.T) {
+ // Test that ResetsInSeconds is correctly extracted
+ input := `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":3600}}")`
+
+ result := parseCodexStderrError(input)
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+
+ if result.ResetsInSeconds != 3600 {
+ t.Errorf("ResetsInSeconds = %d, want 3600", result.ResetsInSeconds)
+ }
+}
diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go
index ff563997a9..82c6ac9dd0 100644
--- a/apps/backend/internal/agentctl/server/process/manager.go
+++ b/apps/backend/internal/agentctl/server/process/manager.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os/exec"
+ "regexp"
"strings"
"sync"
"sync/atomic"
@@ -58,6 +59,9 @@ type PermissionNotification struct {
CreatedAt time.Time `json:"created_at"`
}
+// defaultStderrBufferSize is the number of recent stderr lines to keep for error context
+const defaultStderrBufferSize = 50
+
// Manager manages the agent subprocess
type Manager struct {
cfg *config.InstanceConfig
@@ -72,6 +76,10 @@ type Manager struct {
exitCode atomic.Int32
exitErr atomic.Value // error
+ // Stderr buffering for error context
+ stderrBuffer []string
+ stderrMu sync.RWMutex
+
// Workspace tracker for git status and file changes
workspaceTracker *WorkspaceTracker
@@ -386,7 +394,10 @@ func (m *Manager) createAdapter() error {
case agent.ProtocolACP:
m.adapter = adapter.NewACPAdapter(m.adapterCfg, m.logger)
case agent.ProtocolCodex:
- m.adapter = adapter.NewCodexAdapter(m.adapterCfg, m.logger)
+ codexAdapter := adapter.NewCodexAdapter(m.adapterCfg, m.logger)
+ // Set stderr provider so errors can include recent stderr output
+ codexAdapter.SetStderrProvider(m)
+ m.adapter = codexAdapter
default:
return fmt.Errorf("unsupported protocol: %s", protocol)
}
@@ -528,6 +539,9 @@ func (m *Manager) readStderr() {
for scanner.Scan() {
line := scanner.Text()
m.logger.Debug("agent stderr", zap.String("line", line))
+
+ // Buffer the line for error context
+ m.appendStderr(line)
}
if err := scanner.Err(); err != nil {
@@ -535,6 +549,46 @@ func (m *Manager) readStderr() {
}
}
+// ansiEscapeRegex matches ANSI escape sequences
+var ansiEscapeRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
+
+// stripANSI removes ANSI escape codes from a string
+func stripANSI(s string) string {
+ return ansiEscapeRegex.ReplaceAllString(s, "")
+}
+
+// appendStderr adds a line to the stderr ring buffer
+func (m *Manager) appendStderr(line string) {
+ m.stderrMu.Lock()
+ defer m.stderrMu.Unlock()
+
+ // Strip ANSI escape codes for cleaner display
+ cleanLine := stripANSI(line)
+
+ if len(m.stderrBuffer) >= defaultStderrBufferSize {
+ // Ring buffer: drop oldest line
+ m.stderrBuffer = m.stderrBuffer[1:]
+ }
+ m.stderrBuffer = append(m.stderrBuffer, cleanLine)
+}
+
+// GetRecentStderr returns a copy of the recent stderr lines
+func (m *Manager) GetRecentStderr() []string {
+ m.stderrMu.RLock()
+ defer m.stderrMu.RUnlock()
+
+ result := make([]string, len(m.stderrBuffer))
+ copy(result, m.stderrBuffer)
+ return result
+}
+
+// ClearStderrBuffer clears the stderr buffer (e.g., after successful operation)
+func (m *Manager) ClearStderrBuffer() {
+ m.stderrMu.Lock()
+ defer m.stderrMu.Unlock()
+ m.stderrBuffer = nil
+}
+
// waitForExit waits for the process to exit
func (m *Manager) waitForExit() {
defer m.wg.Done()
diff --git a/apps/backend/internal/agentctl/server/process/runner_test.go b/apps/backend/internal/agentctl/server/process/runner_test.go
index f2053891e3..08dc286169 100644
--- a/apps/backend/internal/agentctl/server/process/runner_test.go
+++ b/apps/backend/internal/agentctl/server/process/runner_test.go
@@ -76,3 +76,76 @@ func TestProcessRunnerCapturesOutput(t *testing.T) {
}
t.Fatal("process output not captured in time")
}
+
+func TestStripANSI(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "no ANSI codes",
+ input: "hello world",
+ expected: "hello world",
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "simple color code",
+ input: "\x1b[31mred text\x1b[0m",
+ expected: "red text",
+ },
+ {
+ name: "bold text",
+ input: "\x1b[1mbold\x1b[0m",
+ expected: "bold",
+ },
+ {
+ name: "multiple colors",
+ input: "\x1b[31mred\x1b[32mgreen\x1b[34mblue\x1b[0m",
+ expected: "redgreenblue",
+ },
+ {
+ name: "256 color code",
+ input: "\x1b[38;5;196mcolored\x1b[0m",
+ expected: "colored",
+ },
+ {
+ name: "RGB color code",
+ input: "\x1b[38;2;255;0;0mred\x1b[0m",
+ expected: "red",
+ },
+ {
+ name: "cursor movement",
+ input: "\x1b[2Amove up\x1b[3Bmove down",
+ expected: "move upmove down",
+ },
+ {
+ name: "clear line",
+ input: "text\x1b[2Kcleared",
+ expected: "textcleared",
+ },
+ {
+ name: "real world npm output",
+ input: "\x1b[32m✓\x1b[39m \x1b[90mCompiled successfully\x1b[39m",
+ expected: "✓ Compiled successfully",
+ },
+ {
+ name: "mixed with newlines",
+ input: "\x1b[31mError:\x1b[0m\nSomething failed\n\x1b[33mWarning:\x1b[0m check logs",
+ expected: "Error:\nSomething failed\nWarning: check logs",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := stripANSI(tt.input)
+ if result != tt.expected {
+ t.Errorf("stripANSI(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/apps/backend/internal/common/constants/timeouts.go b/apps/backend/internal/common/constants/timeouts.go
new file mode 100644
index 0000000000..833cadd612
--- /dev/null
+++ b/apps/backend/internal/common/constants/timeouts.go
@@ -0,0 +1,21 @@
+// Package constants provides application-wide constants and timeouts.
+package constants
+
+import "time"
+
+// Timeouts for various operations.
+const (
+ // AgentLaunchTimeout is the maximum time to wait for agent launch,
+ // including worktree creation and setup script execution.
+ AgentLaunchTimeout = 6 * time.Minute
+
+ // SetupScriptTimeout is the maximum time to wait for a setup script to complete.
+ SetupScriptTimeout = 5 * time.Minute
+
+ // CleanupScriptTimeout is the maximum time to wait for a cleanup script to complete.
+ CleanupScriptTimeout = 5 * time.Minute
+
+ // TaskDeleteTimeout is the maximum time to wait for task deletion,
+ // including cleanup scripts and worktree removal.
+ TaskDeleteTimeout = 2 * time.Minute
+)
diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go
index acfd067763..2cc2d2f786 100644
--- a/apps/backend/internal/orchestrator/event_handlers.go
+++ b/apps/backend/internal/orchestrator/event_handlers.go
@@ -150,18 +150,36 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle
case "error":
// Handle error events
+ // Note: Agent-specific error parsing is done in the adapter layer.
+ // The adapter provides a user-friendly message in the Text field,
+ // with raw details in the Data field for debugging.
if sessionID != "" && s.messageCreator != nil {
+ // Build error message - adapters should provide parsed message in Text field
+ errorMsg := payload.Data.Error
+ if errorMsg == "" {
+ errorMsg = payload.Data.Text
+ }
+ if errorMsg == "" {
+ errorMsg = "An error occurred while processing your request"
+ }
+
+ // Build metadata with all available error context
+ metadata := map[string]interface{}{
+ "provider": "agent",
+ "provider_agent": payload.AgentID,
+ }
+ if payload.Data.Data != nil {
+ metadata["error_data"] = payload.Data.Data
+ }
+
if err := s.messageCreator.CreateSessionMessage(
ctx,
taskID,
- payload.Data.Error,
+ errorMsg,
sessionID,
string(v1.MessageTypeError),
s.getActiveTurnID(sessionID),
- map[string]interface{}{
- "provider": "agent",
- "provider_agent": payload.AgentID,
- },
+ metadata,
false,
); err != nil {
s.logger.Error("failed to create error message",
diff --git a/apps/backend/internal/task/handlers/process_handlers.go b/apps/backend/internal/task/handlers/process_handlers.go
index 73593fd3d9..02d7dfe181 100644
--- a/apps/backend/internal/task/handlers/process_handlers.go
+++ b/apps/backend/internal/task/handlers/process_handlers.go
@@ -364,8 +364,15 @@ func (h *ProcessHandlers) httpListProcesses(c *gin.Context) {
procs, err := h.lifecycleMgr.ListProcesses(listCtx, sessionID)
if err != nil {
var netErr net.Error
- if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &netErr) || strings.Contains(err.Error(), "connection refused") {
- h.logger.Warn("process list unavailable", zap.String("session_id", sessionID), zap.Error(err))
+ // Handle expected "no processes" conditions gracefully:
+ // - no execution found: agent hasn't started yet (async launch)
+ // - connection refused: agent not running
+ // - deadline exceeded: agent not responding
+ if errors.Is(err, context.DeadlineExceeded) ||
+ errors.As(err, &netErr) ||
+ strings.Contains(err.Error(), "connection refused") ||
+ strings.Contains(err.Error(), "no execution found") {
+ h.logger.Debug("process list unavailable (agent not ready)", zap.String("session_id", sessionID), zap.Error(err))
c.JSON(http.StatusOK, []agentctlclient.ProcessInfo{})
return
}
diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go
index f1308b99ec..7982d47b94 100644
--- a/apps/backend/internal/task/handlers/task_handlers.go
+++ b/apps/backend/internal/task/handlers/task_handlers.go
@@ -3,10 +3,10 @@ package handlers
import (
"context"
"net/http"
- "time"
"github.com/gin-gonic/gin"
+ "github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/task/controller"
@@ -184,17 +184,24 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
response := createTaskResponse{TaskDTO: resp}
if body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil {
- startCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
- // Use task description as the initial prompt
- execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, body.Priority, resp.Description)
- if err != nil {
- h.logger.Error("failed to start agent for task", zap.Error(err))
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start agent for task"})
- return
- }
- response.TaskSessionID = execution.SessionID
- response.AgentExecutionID = execution.AgentExecutionID
+ // Launch agent asynchronously so the HTTP request can return immediately.
+ // The frontend will receive WebSocket updates when the agent actually starts.
+ // This allows the "new task" dialog to close right away while setup scripts run.
+ go func() {
+ // Use a longer timeout to accommodate setup scripts (which can take minutes for npm install, etc.)
+ startCtx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout)
+ defer cancel()
+ // Use task description as the initial prompt
+ execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, body.Priority, resp.Description)
+ if err != nil {
+ h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", resp.ID))
+ return
+ }
+ h.logger.Info("agent started for task (async)",
+ zap.String("task_id", resp.ID),
+ zap.String("session_id", execution.SessionID),
+ zap.String("execution_id", execution.AgentExecutionID))
+ }()
}
c.JSON(http.StatusOK, response)
@@ -278,7 +285,7 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
}
func (h *TaskHandlers) httpDeleteTask(c *gin.Context) {
- deleteCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ deleteCtx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout)
defer cancel()
resp, err := h.controller.DeleteTask(deleteCtx, dto.DeleteTaskRequest{ID: c.Param("id")})
if err != nil {
diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go
index 8bc7ffaff5..5f37afce18 100644
--- a/apps/backend/internal/task/models/models.go
+++ b/apps/backend/internal/task/models/models.go
@@ -106,6 +106,8 @@ const (
MessageTypeStatus MessageType = "status"
// MessageTypePermissionRequest is for agent permission requests
MessageTypePermissionRequest MessageType = "permission_request"
+ // MessageTypeScriptExecution is for setup/cleanup script execution messages
+ MessageTypeScriptExecution MessageType = "script_execution"
)
// Message represents a message in a task session
diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go
index e494b68077..2dcc9be0b2 100644
--- a/apps/backend/internal/task/service/service.go
+++ b/apps/backend/internal/task/service/service.go
@@ -1755,6 +1755,21 @@ func (s *Service) DeleteMessage(ctx context.Context, id string) error {
return nil
}
+// UpdateMessage updates an existing message and publishes an event.
+func (s *Service) UpdateMessage(ctx context.Context, message *models.Message) error {
+ if err := s.repo.UpdateMessage(ctx, message); err != nil {
+ s.logger.Error("failed to update message",
+ zap.String("message_id", message.ID),
+ zap.Error(err))
+ return err
+ }
+
+ // Publish message.updated event for real-time streaming
+ s.publishMessageEvent(ctx, events.MessageUpdated, message)
+
+ return nil
+}
+
// AppendMessageContent appends additional content to an existing message.
// This is used for streaming agent responses where content arrives incrementally.
func (s *Service) AppendMessageContent(ctx context.Context, messageID, additionalContent string) error {
diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go
index e56ef6c80b..d1d2ace085 100644
--- a/apps/backend/internal/worktree/manager.go
+++ b/apps/backend/internal/worktree/manager.go
@@ -25,6 +25,16 @@ type Manager struct {
mu sync.RWMutex // Protects worktrees map
repoLocks map[string]*sync.Mutex
repoLockMu sync.Mutex
+
+ // Optional dependencies for script execution
+ repoProvider RepositoryProvider
+ scriptMsgHandler ScriptMessageHandler
+}
+
+// ScriptMessageHandler provides script execution and message streaming.
+type ScriptMessageHandler interface {
+ ExecuteSetupScript(ctx context.Context, req ScriptExecutionRequest) error
+ ExecuteCleanupScript(ctx context.Context, req ScriptExecutionRequest) error
}
// Store is the interface for worktree persistence.
@@ -75,6 +85,16 @@ func NewManager(cfg Config, store Store, log *logger.Logger) (*Manager, error) {
}, nil
}
+// SetRepositoryProvider sets the repository provider for fetching repository information.
+func (m *Manager) SetRepositoryProvider(provider RepositoryProvider) {
+ m.repoProvider = provider
+}
+
+// SetScriptMessageHandler sets the script message handler for executing setup/cleanup scripts.
+func (m *Manager) SetScriptMessageHandler(handler ScriptMessageHandler) {
+ m.scriptMsgHandler = handler
+}
+
// getRepoLock returns a mutex for the given repository path.
func (m *Manager) getRepoLock(repoPath string) *sync.Mutex {
m.repoLockMu.Lock()
@@ -263,6 +283,66 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest) (*Workt
m.mu.Unlock()
}
+ // Execute setup script if configured
+ if m.scriptMsgHandler != nil && m.repoProvider != nil {
+ repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID)
+ if err != nil {
+ m.logger.Warn("failed to fetch repository for setup script",
+ zap.String("repository_id", wt.RepositoryID),
+ zap.Error(err))
+ } else if strings.TrimSpace(repo.SetupScript) != "" {
+ m.logger.Info("executing setup script for worktree",
+ zap.String("worktree_id", wt.ID),
+ zap.String("repository_id", wt.RepositoryID))
+
+ scriptReq := ScriptExecutionRequest{
+ SessionID: wt.SessionID,
+ TaskID: wt.TaskID,
+ RepositoryID: wt.RepositoryID,
+ Script: repo.SetupScript,
+ WorkingDir: wt.Path,
+ ScriptType: "setup",
+ }
+
+ if err := m.scriptMsgHandler.ExecuteSetupScript(ctx, scriptReq); err != nil {
+ // Setup failed - cleanup worktree
+ m.logger.Error("setup script failed, cleaning up worktree",
+ zap.String("worktree_id", wt.ID),
+ zap.Error(err))
+
+ // Remove from cache
+ if wt.SessionID != "" {
+ m.mu.Lock()
+ delete(m.worktrees, wt.SessionID)
+ m.mu.Unlock()
+ }
+
+ // Remove directory
+ if cleanupErr := m.removeWorktreeDir(ctx, wt.Path, req.RepositoryPath); cleanupErr != nil {
+ m.logger.Warn("failed to cleanup worktree after setup failure",
+ zap.Error(cleanupErr))
+ }
+
+ // Mark as deleted in database
+ if m.store != nil {
+ now := time.Now()
+ wt.Status = StatusDeleted
+ wt.DeletedAt = &now
+ wt.UpdatedAt = now
+ if updateErr := m.store.UpdateWorktree(ctx, wt); updateErr != nil {
+ m.logger.Warn("failed to update worktree status",
+ zap.Error(updateErr))
+ }
+ }
+
+ return nil, fmt.Errorf("setup script failed: %w", err)
+ }
+
+ m.logger.Info("setup script completed successfully",
+ zap.String("worktree_id", wt.ID))
+ }
+ }
+
m.logger.Info("created worktree",
zap.String("session_id", req.SessionID),
zap.String("task_id", req.TaskID),
@@ -363,6 +443,39 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch
repoLock.Lock()
defer repoLock.Unlock()
+ // Execute cleanup script BEFORE removing directory
+ if m.scriptMsgHandler != nil && m.repoProvider != nil {
+ repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID)
+ if err != nil {
+ m.logger.Warn("failed to fetch repository for cleanup script",
+ zap.String("repository_id", wt.RepositoryID),
+ zap.Error(err))
+ } else if strings.TrimSpace(repo.CleanupScript) != "" {
+ m.logger.Info("executing cleanup script for worktree",
+ zap.String("worktree_id", wt.ID),
+ zap.String("repository_id", wt.RepositoryID))
+
+ scriptReq := ScriptExecutionRequest{
+ SessionID: wt.SessionID,
+ TaskID: wt.TaskID,
+ RepositoryID: wt.RepositoryID,
+ Script: repo.CleanupScript,
+ WorkingDir: wt.Path,
+ ScriptType: "cleanup",
+ }
+
+ if err := m.scriptMsgHandler.ExecuteCleanupScript(ctx, scriptReq); err != nil {
+ // Log error but continue with deletion
+ m.logger.Warn("cleanup script failed, proceeding with deletion",
+ zap.String("worktree_id", wt.ID),
+ zap.Error(err))
+ } else {
+ m.logger.Info("cleanup script completed successfully",
+ zap.String("worktree_id", wt.ID))
+ }
+ }
+ }
+
// Remove worktree directory
if err := m.removeWorktreeDir(ctx, wt.Path, wt.RepositoryPath); err != nil {
m.logger.Warn("failed to remove worktree directory",
diff --git a/apps/backend/internal/worktree/repository_adapter.go b/apps/backend/internal/worktree/repository_adapter.go
new file mode 100644
index 0000000000..e34e6733ad
--- /dev/null
+++ b/apps/backend/internal/worktree/repository_adapter.go
@@ -0,0 +1,50 @@
+package worktree
+
+import (
+ "context"
+
+ "github.com/kandev/kandev/internal/task/models"
+)
+
+// Repository contains repository information needed for script execution.
+type Repository struct {
+ ID string
+ SetupScript string
+ CleanupScript string
+}
+
+// RepositoryProvider provides access to repository information.
+type RepositoryProvider interface {
+ GetRepository(ctx context.Context, repositoryID string) (*Repository, error)
+}
+
+// RepositoryService interface for task service repository operations.
+type RepositoryService interface {
+ GetRepository(ctx context.Context, id string) (*models.Repository, error)
+}
+
+// RepositoryAdapter adapts the task service repository interface to the worktree manager's needs.
+type RepositoryAdapter struct {
+ repoService RepositoryService
+}
+
+// NewRepositoryAdapter creates a new RepositoryAdapter.
+func NewRepositoryAdapter(repoService RepositoryService) *RepositoryAdapter {
+ return &RepositoryAdapter{
+ repoService: repoService,
+ }
+}
+
+// GetRepository fetches repository information from the task service.
+func (a *RepositoryAdapter) GetRepository(ctx context.Context, repositoryID string) (*Repository, error) {
+ repo, err := a.repoService.GetRepository(ctx, repositoryID)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Repository{
+ ID: repo.ID,
+ SetupScript: repo.SetupScript,
+ CleanupScript: repo.CleanupScript,
+ }, nil
+}
diff --git a/apps/backend/internal/worktree/script_message_handler.go b/apps/backend/internal/worktree/script_message_handler.go
new file mode 100644
index 0000000000..263c6f03d1
--- /dev/null
+++ b/apps/backend/internal/worktree/script_message_handler.go
@@ -0,0 +1,360 @@
+package worktree
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os/exec"
+ "sync"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/kandev/kandev/internal/common/logger"
+ "github.com/kandev/kandev/internal/task/models"
+)
+
+// ScriptExecutionRequest contains parameters for executing a setup or cleanup script.
+type ScriptExecutionRequest struct {
+ SessionID string
+ TaskID string
+ RepositoryID string
+ Script string
+ WorkingDir string
+ ScriptType string // "setup" or "cleanup"
+}
+
+// DefaultScriptMessageHandler manages script execution and streams output to session messages.
+type DefaultScriptMessageHandler struct {
+ logger *logger.Logger
+ taskService TaskService
+ timeout time.Duration
+}
+
+// TaskService interface for creating and updating messages.
+type TaskService interface {
+ CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error)
+ UpdateMessage(ctx context.Context, message *models.Message) error
+}
+
+// CreateMessageRequest contains parameters for creating a message.
+type CreateMessageRequest struct {
+ TaskSessionID string
+ TaskID string
+ TurnID string
+ Content string
+ AuthorType string
+ AuthorID string
+ RequestsInput bool
+ Type string
+ Metadata map[string]interface{}
+}
+
+// NewDefaultScriptMessageHandler creates a new DefaultScriptMessageHandler.
+func NewDefaultScriptMessageHandler(
+ log *logger.Logger,
+ taskSvc TaskService,
+ timeout time.Duration,
+) *DefaultScriptMessageHandler {
+ return &DefaultScriptMessageHandler{
+ logger: log.WithFields(zap.String("component", "script-message-handler")),
+ taskService: taskSvc,
+ timeout: timeout,
+ }
+}
+
+// ExecuteSetupScript executes a setup script and streams output to a session message.
+// Returns an error if the script fails (non-zero exit code or timeout).
+func (h *DefaultScriptMessageHandler) ExecuteSetupScript(ctx context.Context, req ScriptExecutionRequest) error {
+ return h.executeScript(ctx, req, true)
+}
+
+// ExecuteCleanupScript executes a cleanup script and streams output to a session message.
+// Returns nil even if the script fails (best-effort cleanup).
+func (h *DefaultScriptMessageHandler) ExecuteCleanupScript(ctx context.Context, req ScriptExecutionRequest) error {
+ err := h.executeScript(ctx, req, false)
+ if err != nil {
+ h.logger.Warn("cleanup script failed, continuing with deletion",
+ zap.String("session_id", req.SessionID),
+ zap.Error(err))
+ return nil
+ }
+ return nil
+}
+
+// executeScript is the core implementation for script execution.
+// Note: The parent context is intentionally not used - we create a detached context
+// to prevent HTTP request timeouts from cancelling long-running scripts.
+func (h *DefaultScriptMessageHandler) executeScript(_ context.Context, req ScriptExecutionRequest, failOnError bool) error {
+ if h.taskService == nil {
+ h.logger.Debug("script handler not fully configured, skipping",
+ zap.String("script_type", req.ScriptType))
+ return nil
+ }
+
+ // Create a detached context for script execution with its own timeout.
+ // This prevents the HTTP request context from cancelling the script.
+ scriptCtx, cancel := context.WithTimeout(context.Background(), h.timeout)
+ defer cancel()
+
+ // Create initial message (best-effort - session may not exist during cleanup)
+ msg, err := h.createScriptMessage(scriptCtx, req)
+ if err != nil {
+ // For cleanup scripts, if session doesn't exist, run the script anyway without a message
+ if req.ScriptType == "cleanup" {
+ h.logger.Warn("failed to create cleanup script message, running script without message tracking",
+ zap.String("session_id", req.SessionID),
+ zap.Error(err))
+ // Run script directly without message tracking
+ return h.runScriptWithoutMessage(scriptCtx, req, failOnError)
+ }
+ // For setup scripts, this is a hard error
+ return fmt.Errorf("failed to create script message: %w", err)
+ }
+
+ h.logger.Info("created script execution message",
+ zap.String("message_id", msg.ID),
+ zap.String("session_id", req.SessionID),
+ zap.String("script_type", req.ScriptType))
+
+ // Update message status to running
+ msg.Metadata["status"] = "running"
+ if err := h.taskService.UpdateMessage(scriptCtx, msg); err != nil {
+ h.logger.Warn("failed to update message with running status",
+ zap.String("message_id", msg.ID),
+ zap.Error(err))
+ }
+
+ // Execute the script and capture output
+ exitCode, scriptErr := h.runScriptWithOutput(scriptCtx, req, msg)
+
+ // Update final status
+ if scriptErr != nil {
+ msg.Metadata["status"] = "failed"
+ msg.Metadata["error"] = scriptErr.Error()
+ if msg.Content == "" {
+ msg.Content = fmt.Sprintf("Script execution failed: %s", scriptErr.Error())
+ } else {
+ msg.Content += fmt.Sprintf("\n\nScript execution failed: %s", scriptErr.Error())
+ }
+ } else if exitCode == 0 {
+ msg.Metadata["status"] = "exited"
+ msg.Metadata["exit_code"] = exitCode
+ if msg.Content == "" {
+ msg.Content = "Script completed successfully"
+ }
+ } else {
+ msg.Metadata["status"] = "failed"
+ msg.Metadata["exit_code"] = exitCode
+ if msg.Content == "" {
+ msg.Content = fmt.Sprintf("Script failed with exit code: %d", exitCode)
+ } else {
+ msg.Content += fmt.Sprintf("\n\nScript failed with exit code: %d", exitCode)
+ }
+ }
+
+ msg.Metadata["completed_at"] = time.Now().UTC().Format(time.RFC3339Nano)
+ if updateErr := h.taskService.UpdateMessage(scriptCtx, msg); updateErr != nil {
+ h.logger.Warn("failed to update message with final status",
+ zap.String("message_id", msg.ID),
+ zap.Error(updateErr))
+ }
+
+ h.logger.Info("script execution completed",
+ zap.String("message_id", msg.ID),
+ zap.Int("exit_code", exitCode),
+ zap.Bool("success", exitCode == 0))
+
+ // Return error if setup script failed
+ if failOnError && (scriptErr != nil || exitCode != 0) {
+ if scriptErr != nil {
+ return scriptErr
+ }
+ return fmt.Errorf("script exited with code %d", exitCode)
+ }
+
+ return nil
+}
+
+// createScriptMessage creates the initial script execution message.
+func (h *DefaultScriptMessageHandler) createScriptMessage(ctx context.Context, req ScriptExecutionRequest) (*models.Message, error) {
+ metadata := map[string]interface{}{
+ "script_type": req.ScriptType,
+ "command": req.Script,
+ "status": "starting",
+ "started_at": time.Now().UTC().Format(time.RFC3339Nano),
+ }
+
+ createReq := &CreateMessageRequest{
+ TaskSessionID: req.SessionID,
+ TaskID: req.TaskID,
+ Content: "", // Will be populated with output
+ AuthorType: "agent",
+ Type: "script_execution",
+ Metadata: metadata,
+ }
+
+ return h.taskService.CreateMessage(ctx, createReq)
+}
+
+// runScriptWithOutput runs the script and captures output, streaming it to the message.
+// The passed context should already have an appropriate timeout set.
+func (h *DefaultScriptMessageHandler) runScriptWithOutput(ctx context.Context, req ScriptExecutionRequest, msg *models.Message) (int, error) {
+ // Run script with sh -c to support complex commands
+ cmd := exec.CommandContext(ctx, "sh", "-c", req.Script)
+ cmd.Dir = req.WorkingDir
+
+ // Create pipes for stdout and stderr
+ stdoutPipe, err := cmd.StdoutPipe()
+ if err != nil {
+ return -1, fmt.Errorf("failed to create stdout pipe: %w", err)
+ }
+ stderrPipe, err := cmd.StderrPipe()
+ if err != nil {
+ return -1, fmt.Errorf("failed to create stderr pipe: %w", err)
+ }
+
+ // Start the command
+ if err := cmd.Start(); err != nil {
+ return -1, fmt.Errorf("failed to start script: %w", err)
+ }
+
+ h.logger.Info("script process started",
+ zap.String("message_id", msg.ID),
+ zap.String("command", req.Script))
+
+ // Stream output
+ var outputBuf bytes.Buffer
+ var wg sync.WaitGroup
+ var mu sync.Mutex
+
+ // Stream stdout
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ buf := make([]byte, 1024)
+ for {
+ n, err := stdoutPipe.Read(buf)
+ if n > 0 {
+ mu.Lock()
+ outputBuf.Write(buf[:n])
+ msg.Content = outputBuf.String()
+ mu.Unlock()
+
+ // Update message with incremental output (best-effort)
+ if updateErr := h.taskService.UpdateMessage(context.Background(), msg); updateErr != nil {
+ h.logger.Debug("failed to update message with output",
+ zap.String("message_id", msg.ID),
+ zap.Error(updateErr))
+ }
+ }
+ if err != nil {
+ if err != io.EOF {
+ h.logger.Debug("error reading stdout",
+ zap.Error(err))
+ }
+ break
+ }
+ }
+ }()
+
+ // Stream stderr
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ buf := make([]byte, 1024)
+ for {
+ n, err := stderrPipe.Read(buf)
+ if n > 0 {
+ mu.Lock()
+ outputBuf.Write(buf[:n])
+ msg.Content = outputBuf.String()
+ mu.Unlock()
+
+ // Update message with incremental output (best-effort)
+ if updateErr := h.taskService.UpdateMessage(context.Background(), msg); updateErr != nil {
+ h.logger.Debug("failed to update message with output",
+ zap.String("message_id", msg.ID),
+ zap.Error(updateErr))
+ }
+ }
+ if err != nil {
+ if err != io.EOF {
+ h.logger.Debug("error reading stderr",
+ zap.Error(err))
+ }
+ break
+ }
+ }
+ }()
+
+ // Wait for output streaming to finish
+ wg.Wait()
+
+ // Wait for command to complete
+ err = cmd.Wait()
+
+ // Get exit code
+ exitCode := 0
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ exitCode = exitErr.ExitCode()
+ } else {
+ // Other error (e.g., timeout, command not found)
+ return -1, err
+ }
+ }
+
+ return exitCode, nil
+}
+
+// runScriptWithoutMessage runs a script without message tracking (used when session is deleted).
+// The passed context should already have an appropriate timeout set.
+func (h *DefaultScriptMessageHandler) runScriptWithoutMessage(ctx context.Context, req ScriptExecutionRequest, failOnError bool) error {
+ h.logger.Info("executing script without message tracking",
+ zap.String("script_type", req.ScriptType),
+ zap.String("command", req.Script))
+
+ // Run script with sh -c
+ cmd := exec.CommandContext(ctx, "sh", "-c", req.Script)
+ cmd.Dir = req.WorkingDir
+
+ // Capture output for logging
+ output, err := cmd.CombinedOutput()
+
+ // Get exit code
+ exitCode := 0
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ exitCode = exitErr.ExitCode()
+ } else {
+ h.logger.Error("script execution failed",
+ zap.String("script_type", req.ScriptType),
+ zap.Error(err),
+ zap.String("output", string(output)))
+ if failOnError {
+ return err
+ }
+ return nil
+ }
+ }
+
+ // Log the result
+ if exitCode == 0 {
+ h.logger.Info("script completed successfully",
+ zap.String("script_type", req.ScriptType),
+ zap.Int("exit_code", exitCode),
+ zap.String("output", string(output)))
+ } else {
+ h.logger.Warn("script failed",
+ zap.String("script_type", req.ScriptType),
+ zap.Int("exit_code", exitCode),
+ zap.String("output", string(output)))
+ if failOnError {
+ return fmt.Errorf("script exited with code %d", exitCode)
+ }
+ }
+
+ return nil
+}
diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx
index f5aa9e76f5..7caf504d3b 100644
--- a/apps/web/components/task/chat/message-renderer.tsx
+++ b/apps/web/components/task/chat/message-renderer.tsx
@@ -8,6 +8,7 @@ import { StatusMessage } from '@/components/task/chat/messages/status-message';
import { ToolCallMessage } from '@/components/task/chat/messages/tool-call-message';
import { ThinkingMessage } from '@/components/task/chat/messages/thinking-message';
import { TodoMessage } from '@/components/task/chat/messages/todo-message';
+import { ScriptExecutionMessage } from '@/components/task/chat/messages/script-execution-message';
type AdapterContext = {
isTaskDescription: boolean;
@@ -46,6 +47,10 @@ const adapters: MessageAdapter[] = [
matches: (comment) => comment.type === 'permission_request',
render: (comment) =>
+ {comment.content}
+
+ )}
+
+ {comment.content}
+
+
+ {detail.value}
+
+