@@ -21,9 +21,10 @@ import (
21
21
)
22
22
23
23
type WorkspaceBashArgs struct {
24
- Workspace string `json:"workspace"`
25
- Command string `json:"command"`
26
- TimeoutMs int `json:"timeout_ms,omitempty"`
24
+ Workspace string `json:"workspace"`
25
+ Command string `json:"command"`
26
+ TimeoutMs int `json:"timeout_ms,omitempty"`
27
+ Background bool `json:"background,omitempty"`
27
28
}
28
29
29
30
type WorkspaceBashResult struct {
@@ -50,9 +51,13 @@ The workspace parameter supports various formats:
50
51
The timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).
51
52
If the command times out, all output captured up to that point is returned with a cancellation message.
52
53
54
+ For background commands (background: true), output is captured until the timeout is reached, then the command
55
+ continues running in the background. The captured output is returned as the result.
56
+
53
57
Examples:
54
58
- workspace: "my-workspace", command: "ls -la"
55
59
- workspace: "john/dev-env", command: "git status", timeout_ms: 30000
60
+ - workspace: "my-workspace", command: "npm run dev", background: true, timeout_ms: 10000
56
61
- workspace: "my-workspace.main", command: "docker ps"` ,
57
62
Schema : aisdk.Schema {
58
63
Properties : map [string ]any {
@@ -70,6 +75,10 @@ Examples:
70
75
"default" : 60000 ,
71
76
"minimum" : 1 ,
72
77
},
78
+ "background" : map [string ]any {
79
+ "type" : "boolean" ,
80
+ "description" : "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background." ,
81
+ },
73
82
},
74
83
Required : []string {"workspace" , "command" },
75
84
},
@@ -137,23 +146,35 @@ Examples:
137
146
138
147
// Set default timeout if not specified (60 seconds)
139
148
timeoutMs := args .TimeoutMs
149
+ defaultTimeoutMs := 60000
140
150
if timeoutMs <= 0 {
141
- timeoutMs = 60000
151
+ timeoutMs = defaultTimeoutMs
152
+ }
153
+ command := args .Command
154
+ if args .Background {
155
+ // For background commands, use nohup directly to ensure they survive SSH session
156
+ // termination. This captures output normally but allows the process to continue
157
+ // running even after the SSH connection closes.
158
+ command = fmt .Sprintf ("nohup %s </dev/null 2>&1" , args .Command )
142
159
}
143
160
144
- // Create context with timeout
145
- ctx , cancel = context .WithTimeout (ctx , time .Duration (timeoutMs )* time .Millisecond )
146
- defer cancel ()
161
+ // Create context with command timeout (replace the broader MCP timeout)
162
+ commandCtx , commandCancel : = context .WithTimeout (ctx , time .Duration (timeoutMs )* time .Millisecond )
163
+ defer commandCancel ()
147
164
148
165
// Execute command with timeout handling
149
- output , err := executeCommandWithTimeout (ctx , session , args . Command )
166
+ output , err := executeCommandWithTimeout (commandCtx , session , command )
150
167
outputStr := strings .TrimSpace (string (output ))
151
168
152
169
// Handle command execution results
153
170
if err != nil {
154
171
// Check if the command timed out
155
- if errors .Is (context .Cause (ctx ), context .DeadlineExceeded ) {
156
- outputStr += "\n Command canceled due to timeout"
172
+ if errors .Is (context .Cause (commandCtx ), context .DeadlineExceeded ) {
173
+ if args .Background {
174
+ outputStr += "\n Command continues running in background"
175
+ } else {
176
+ outputStr += "\n Command canceled due to timeout"
177
+ }
157
178
return WorkspaceBashResult {
158
179
Output : outputStr ,
159
180
ExitCode : 124 ,
@@ -387,21 +408,27 @@ func executeCommandWithTimeout(ctx context.Context, session *gossh.Session, comm
387
408
return safeWriter .Bytes (), err
388
409
case <- ctx .Done ():
389
410
// Context was canceled (timeout or other cancellation)
390
- // Close the session to stop the command
391
- _ = session .Close ()
411
+ // Close the session to stop the command, but handle errors gracefully
412
+ closeErr : = session .Close ()
392
413
393
- // Give a brief moment to collect any remaining output
394
- timer := time .NewTimer (50 * time .Millisecond )
414
+ // Give a brief moment to collect any remaining output and for goroutines to finish
415
+ timer := time .NewTimer (100 * time .Millisecond )
395
416
defer timer .Stop ()
396
417
397
418
select {
398
419
case <- timer .C :
399
420
// Timer expired, return what we have
421
+ break
400
422
case err := <- done :
401
423
// Command finished during grace period
402
- return safeWriter .Bytes (), err
424
+ if closeErr == nil {
425
+ return safeWriter .Bytes (), err
426
+ }
427
+ // If session close failed, prioritize the context error
428
+ break
403
429
}
404
430
431
+ // Return the collected output with the context error
405
432
return safeWriter .Bytes (), context .Cause (ctx )
406
433
}
407
434
}
@@ -421,5 +448,9 @@ func (sw *syncWriter) Write(p []byte) (n int, err error) {
421
448
func (sw * syncWriter ) Bytes () []byte {
422
449
sw .mu .Lock ()
423
450
defer sw .mu .Unlock ()
424
- return sw .w .Bytes ()
451
+ // Return a copy to prevent race conditions with the underlying buffer
452
+ b := sw .w .Bytes ()
453
+ result := make ([]byte , len (b ))
454
+ copy (result , b )
455
+ return result
425
456
}
0 commit comments