@@ -4,10 +4,15 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "io"
8+ "sort"
79 "strings"
10+ "sync"
811 "time"
912
1013 claude "github.com/partio-io/claude-agent-sdk-go"
14+
15+ "github.com/rebelopsio/archy/internal/config"
1116)
1217
1318// RunRequest describes a single skill execution.
@@ -45,6 +50,10 @@ type RunResult struct {
4550 // CostUSD is the model's reported cost for this run, if available.
4651 // Zero means unknown.
4752 CostUSD float64
53+ // SubprocessStderr is everything the claude CLI subprocess wrote
54+ // to its stderr during this run, joined newline-per-callback.
55+ // Empty when the subprocess produced no stderr output.
56+ SubprocessStderr string
4857}
4958
5059// ToolCallRecord is one tool invocation observed during a run.
@@ -85,6 +94,23 @@ func (r *Runtime) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
8594 }
8695 opts = append (opts , claude .WithAppendSystemPrompt (systemPromptAddition (req )))
8796
97+ // Capture the claude CLI subprocess's stderr so silent-failure
98+ // modes (subprocess exits before consulting the model) surface
99+ // the underlying error. The callback fires from the SDK's drain
100+ // goroutine, so guard the buffer with a mutex.
101+ var (
102+ stderrMu sync.Mutex
103+ stderrBuf strings.Builder
104+ )
105+ opts = append (opts , claude .WithStderrCallback (func (line string ) {
106+ stderrMu .Lock ()
107+ defer stderrMu .Unlock ()
108+ stderrBuf .WriteString (line )
109+ stderrBuf .WriteByte ('\n' )
110+ }))
111+
112+ logSDKInvocation (r .stderrLog , r .cfg , r .opts , opts , req )
113+
88114 emit := func (ev ProgressEvent ) {
89115 if req .ProgressFn != nil {
90116 req .ProgressFn (ev )
@@ -95,6 +121,14 @@ func (r *Runtime) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
95121 emit (ProgressEvent {Kind : ProgressStart , At : start })
96122
97123 res := & RunResult {}
124+ // readStderr returns the captured subprocess output. Called on
125+ // every return path so RunResult.SubprocessStderr is always
126+ // populated (empty when nothing was written).
127+ readStderr := func () string {
128+ stderrMu .Lock ()
129+ defer stderrMu .Unlock ()
130+ return stderrBuf .String ()
131+ }
98132 pending := make (map [string ]* ToolCallRecord ) // tool_use_id → in-flight record
99133 var assistantText strings.Builder
100134 systemSeen := false
@@ -105,6 +139,11 @@ func (r *Runtime) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
105139 if ctx .Err () != nil && (errors .Is (err , context .Canceled ) || errors .Is (err , context .DeadlineExceeded )) {
106140 return nil , fmt .Errorf ("agent run canceled: %w" , ctx .Err ())
107141 }
142+ // Surface the subprocess stderr alongside the SDK error so
143+ // the operator sees what claude actually said before exiting.
144+ if s := readStderr (); s != "" {
145+ return nil , fmt .Errorf ("%w: %v (claude stderr: %s)" , ErrRun , err , s )
146+ }
108147 return nil , fmt .Errorf ("%w: %v" , ErrRun , err )
109148 }
110149 if ctx .Err () != nil {
@@ -143,17 +182,47 @@ func (r *Runtime) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
143182 emit (ProgressEvent {Kind : ProgressEnd , Message : endMsg , At : time .Now ()})
144183 res .Text = assistantText .String ()
145184 res .Duration = time .Since (start )
185+ res .SubprocessStderr = readStderr ()
146186 return res , fmt .Errorf ("%w: %s" , ErrRun , endMsg )
147187 }
148188 }
149189 }
150190
151191 res .Text = assistantText .String ()
152192 res .Duration = time .Since (start )
193+ res .SubprocessStderr = readStderr ()
153194 emit (ProgressEvent {Kind : ProgressEnd , Message : "completed" , At : time .Now ()})
154195 return res , nil
155196}
156197
198+ // logSDKInvocation writes a one-time summary of the agent invocation
199+ // to w. This is the last thing archy controls before subprocess
200+ // handoff; if claude exits without consulting the model, the answer
201+ // is almost certainly in what we passed it. Secrets (bearer tokens,
202+ // auth headers) are never logged.
203+ func logSDKInvocation (w io.Writer , cfg * config.Config , opts Options , sdkOpts []claude.Option , req RunRequest ) {
204+ mcpEnabled := []string {}
205+ for name , srv := range cfg .MCPServers {
206+ if srv .Enabled {
207+ mcpEnabled = append (mcpEnabled , name )
208+ }
209+ }
210+ sort .Strings (mcpEnabled )
211+
212+ _ , _ = fmt .Fprintln (w , "archy agent invocation:" )
213+ _ , _ = fmt .Fprintf (w , " skill=%s\n " , req .SkillName )
214+ _ , _ = fmt .Fprintf (w , " model=%s\n " , cfg .Agent .Model )
215+ _ , _ = fmt .Fprintf (w , " max_turns=%d\n " , cfg .Agent .MaxTurns )
216+ _ , _ = fmt .Fprintf (w , " permission_mode=%s\n " , cfg .Agent .PermissionMode )
217+ _ , _ = fmt .Fprintf (w , " cwd=%s\n " , opts .Cwd )
218+ _ , _ = fmt .Fprintf (w , " cli_path=%s\n " , opts .CLIPath )
219+ _ , _ = fmt .Fprintf (w , " archy_binary=%s\n " , opts .ArchyBinaryPath )
220+ _ , _ = fmt .Fprintf (w , " sdk_option_count=%d\n " , len (sdkOpts ))
221+ _ , _ = fmt .Fprintf (w , " mcp_servers_enabled=%v\n " , mcpEnabled )
222+ _ , _ = fmt .Fprintf (w , " skills_project_dir=%s\n " , cfg .Skills .ProjectDir )
223+ _ , _ = fmt .Fprintf (w , " skills_user_dir=%s\n " , cfg .Skills .UserDir )
224+ }
225+
157226// systemPromptAddition is the one-line skill-invocation instruction the
158227// runtime appends via [claude.WithAppendSystemPrompt]. Skill authors
159228// can rely on the agent seeing this exact phrasing.
0 commit comments