Bash runs one shell command through /bin/sh -c and returns combined stdout+stderr. Non-zero exit is not an error; the model sees the failure and reacts. A pre-cancelled parent (Ctrl+C raced the dispatch) returns "(cancelled)" before the blank-command check, so a trivially-cancelled call isn't mista
(parent context.Context, command string, timeout time.Duration)
| 36 | // before the blank-command check, so a trivially-cancelled call isn't |
| 37 | // mistaken for a valid empty-args invocation. |
| 38 | func Bash(parent context.Context, command string, timeout time.Duration) string { |
| 39 | if parent.Err() != nil { |
| 40 | return "(cancelled)" |
| 41 | } |
| 42 | if strings.TrimSpace(command) == "" { |
| 43 | return "(empty command)" |
| 44 | } |
| 45 | ctxT, cancel := context.WithTimeout(parent, timeout) |
| 46 | defer cancel() |
| 47 | |
| 48 | cmd := exec.CommandContext(ctxT, "/bin/sh", "-c", command) |
| 49 | // Shell gets its own process group + a Cancel that kills the whole group |
| 50 | // on cancel/timeout (Unix; no-op on Windows). Without it, backgrounded |
| 51 | // children (`cmd &`) outlive the parent shell and leak. |
| 52 | setProcessGroup(cmd) |
| 53 | // Cap the wait for stdout/stderr pipes to close after /bin/sh exits. |
| 54 | // Backgrounded children inherit those pipe fds, so without this Run's |
| 55 | // pipe-copy goroutine blocks for the full timeout even though the shell |
| 56 | // is gone. |
| 57 | cmd.WaitDelay = 100 * time.Millisecond |
| 58 | // Bounded combined output instead of CombinedOutput's unbounded buffer: a |
| 59 | // high-throughput command (`cat big.iso`, `grep -r "" /`) can emit hundreds |
| 60 | // of MB/s and OOM-kill the whole TUI well before the timeout or Ctrl+C |
| 61 | // react. ctx.Truncate keeps only head+tail anyway, so nothing the model |
| 62 | // would see is lost. Stdout and Stderr get the SAME writer value, which |
| 63 | // os/exec detects and funnels through one pipe: no locking needed. |
| 64 | buf := &headTailBuffer{} |
| 65 | cmd.Stdout = buf |
| 66 | cmd.Stderr = buf |
| 67 | err := cmd.Run() |
| 68 | s := buf.String() |
| 69 | // Name the capture drop at the END of the output, where ctx.Truncate's |
| 70 | // tail keep guarantees the model sees it: the in-band seam marker sits at |
| 71 | // the ~1MB offset, always inside Truncate's dropped middle, and Truncate's |
| 72 | // own "total" would count the collapsed string, under-reporting the real |
| 73 | // size by orders of magnitude. |
| 74 | if d := buf.droppedBytes(); d > 0 { |
| 75 | s += fmt.Sprintf("\n(output capped at capture: %d bytes total, %d bytes dropped mid-stream)", buf.totalBytes(), d) |
| 76 | } |
| 77 | if err != nil { |
| 78 | switch { |
| 79 | case ctxT.Err() == context.DeadlineExceeded: |
| 80 | return s + fmt.Sprintf("\n(timeout after %s)", timeout) |
| 81 | case parent.Err() == context.Canceled || ctxT.Err() == context.Canceled: |
| 82 | // User Ctrl+C; name it rather than leak "signal: killed" noise. |
| 83 | return s + "\n(cancelled)" |
| 84 | case errors.Is(err, exec.ErrWaitDelay): |
| 85 | // Shell exited 0; err is non-nil only because a backgrounded child |
| 86 | // held the pipes past WaitDelay, not a failure. Return output as-is |
| 87 | // so it isn't mislabeled with a spurious (exit: ...). After the |
| 88 | // cancel/timeout cases so those signals win over a coincident delay. |
| 89 | return s |
| 90 | default: |
| 91 | // Exit errors go into the output, exactly what the model needs. |
| 92 | s += fmt.Sprintf("\n(exit: %v)", err) |
| 93 | } |
| 94 | } |
| 95 | return s |