Terminal : le vrai code de sortie, la sortie gardée au délai, sans ANSI

Repris de shell.ts / shell-tail.ts et diagnostics.ts d'OpenFox (2.0.160).

- « cmd | tail -N » : le code de sortie devenait celui de tail (0) et un
  build en échec passait pour réussi. Le tail est retiré de la commande et
  Loki garde lui-même les N dernières lignes : même sortie, vrai code.
- Délai dépassé : la sortie déjà produite est rendue (où en était le build,
  quel test bloquait) au lieu d'un « [timeout] » sec, avec le conseil de
  passer par bash_bg pour un serveur.
- Couleurs et séquences ANSI retirées : du bruit en tokens.
- La durée suit le code de sortie (« exit: 0 · 1.2s »).
- Mode Code : « cmd & » refusé, avec renvoi vers bash_bg — le process
  orphelin n'avait ni sortie lisible ni moyen d'être arrêté.
- Diagnostics LSP : erreurs d'abord, avec le compte total ; au-delà de la
  limite, des indices de style passaient devant l'erreur de compilation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:34:42 +02:00
1 parent a745d2399e
commit 2e0356cd6c
5 files changed
+175 -4

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
package loki
// chat_shell_hygiene.go — ce que le terminal rend au modèle, nettoyé (repris de
// shell.ts / shell-tail.ts d'OpenFox, 2.0.160) :
//
// - « cmd | tail -N » : le code de sortie devenait celui de tail (0), et un
// build en échec passait pour réussi. On retire le tail de la commande et
// on garde nous-mêmes les N dernières lignes : même sortie, vrai code.
// - séquences ANSI (couleurs, curseur) : du bruit en tokens, illisible ;
// - « cmd & » en mode Code : le process orphelin n'a ni sortie ni arrêt —
// bash_bg est fait pour ça.
import (
"regexp"
"strconv"
"strings"
)
// tailPipeRe : « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en
// toute fin de commande.
var tailPipeRe = regexp.MustCompile(`\s*\|\s*tail(?:\s+-n\s*(\d+)|\s+-(\d+))?\s*$`)
// splitTailPipe retire un « | tail -N » final. Renvoie la commande sans lui et
// N (0 = pas de tail ; 10 = tail sans nombre, sa valeur par défaut).
func splitTailPipe(command string) (string, int) {
m := tailPipeRe.FindStringSubmatchIndex(command)
if m == nil {
return command, 0
}
n := 10
for _, g := range []int{2, 4} {
if m[g] >= 0 {
if v, err := strconv.Atoi(command[m[g]:m[g+1]]); err == nil && v > 0 {
n = v
}
}
}
rest := strings.TrimSpace(command[:m[0]])
if rest == "" {
return command, 0
}
return rest, n
}
// shellANSIRe : séquences CSI (couleurs, curseur) et OSC (titres de fenêtre).
var shellANSIRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`)
func stripANSI(s string) string {
if !strings.Contains(s, "\x1b") {
return s
}
return shellANSIRe.ReplaceAllString(s, "")
}
// trailingBackground : la commande se termine par un « & » de mise en
// arrière-plan (pas « && »).
func trailingBackground(command string) bool {
c := strings.TrimSpace(command)
return strings.HasSuffix(c, "&") && !strings.HasSuffix(c, "&&")
}
+58
View File
@@ -0,0 +1,58 @@
package loki
import (
"context"
"runtime"
"strings"
"testing"
)
func TestSplitTailPipe(t *testing.T) {
cas := map[string]struct {
cmd string
n int
}{
"go build ./... 2>&1 | tail -20": {"go build ./... 2>&1", 20},
"make | tail -n 5": {"make", 5},
"make|tail -n40": {"make", 40},
"go test | tail": {"go test", 10},
"tail -f log.txt": {"tail -f log.txt", 0},
"cat a | tail -5 | grep x": {"cat a | tail -5 | grep x", 0},
"echo ok": {"echo ok", 0},
}
for in, want := range cas {
if cmd, n := splitTailPipe(in); cmd != want.cmd || n != want.n {
t.Errorf("%q → (%q, %d), attendu (%q, %d)", in, cmd, n, want.cmd, want.n)
}
}
if !trailingBackground("./serveur &") || trailingBackground("make && make test") || trailingBackground("ls 2>&1") {
t.Error("trailingBackground se trompe")
}
if got := stripANSI("\x1b[1;31mFAIL\x1b[0m ok"); got != "FAIL ok" {
t.Errorf("stripANSI = %q", got)
}
}
// « cmd | tail -N » rend le VRAI code de sortie, et seulement N lignes.
func TestShellTailGardeLeCodeDeSortie(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell POSIX")
}
withWorkspace(t)
out := runShell(context.Background(), "sh -c 'printf \"l1\nl2\nl3\n\"; exit 2' | tail -n 2", 10)
if !strings.HasPrefix(out, "exit: 2") || strings.Contains(out, "l1") || !strings.Contains(out, "l3") {
t.Fatalf("sortie = %q", out)
}
}
// Au délai dépassé, la sortie déjà produite est rendue.
func TestShellTimeoutGardeLaSortie(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell POSIX")
}
withWorkspace(t)
out := runShell(context.Background(), "echo avant; sleep 5", 1)
if !strings.Contains(out, "timeout") || !strings.Contains(out, "avant") {
t.Fatalf("sortie = %q", out)
}
}
+25 -4
View File
@@ -224,6 +224,10 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
if timeoutSec > toolMaxTimeout {
timeoutSec = toolMaxTimeout
}
// « … | tail -N » final : retiré, on garde nous-mêmes les N dernières lignes
// (chat_shell_hygiene.go) — sinon le code de sortie est celui de tail.
command, keepLines := splitTailPipe(command)
start := time.Now()
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
@@ -254,9 +258,27 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// issue connue était de redémarrer loki-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
// demandait un tail.
clean := func(s string) string { return tailOutput(stripANSI(s)) }
out := stdout.String()
if keepLines > 0 {
out = tailLines(out, keepLines)
}
out = clean(out)
errOut := clean(stderr.String())
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
// La sortie déjà produite reste utile (où le build en était, quel test
// bloquait) : on la rend au lieu d'un « timeout » sec (OpenFox).
msg := fmt.Sprintf("[timeout après %ds — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]", timeoutSec)
if out != "" {
msg += "\n\nstdout (partiel):\n" + out
}
if errOut != "" {
msg += "\n\nstderr (partiel):\n" + errOut
}
return msg
case errors.Is(parent.Err(), context.Canceled):
return "[commande interrompue]"
}
@@ -268,9 +290,8 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
return fmt.Sprintf("[erreur: %v]", err)
}
}
out := tailOutput(stdout.String())
errOut := tailOutput(stderr.String())
parts := []string{fmt.Sprintf("exit: %d", exit)}
// « exit: N » en tête : tasks_script.go s'y fie. La durée suit.
parts := []string{fmt.Sprintf("exit: %d · %s", exit, time.Since(start).Round(100*time.Millisecond))}
if out != "" {
parts = append(parts, "stdout:\n"+out)
}
+6
View File
@@ -1728,6 +1728,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
result = refusedCommandResult(reason)
break
}
// Mode Code : « cmd & » laisse un process orphelin, sans sortie
// lisible ni moyen de l'arrêter — bash_bg est fait pour ça.
if caps.Code && trailingBackground(label) {
result = "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash."
break
}
if tgt := agentTargetSlug(); tgt != "" {
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
+26
View File
@@ -22,6 +22,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -145,10 +146,35 @@ func lspDiagnosticsFor(absPath string) string {
if len(diags) == 0 {
return ""
}
// Erreurs d'abord, puis avertissements, puis le reste (OpenFox) : au-delà
// de lspMaxDiags on tronque, et des indices de style passaient devant
// l'erreur de compilation qui comptait. Le compte annonce le total.
rank := func(s int) int {
if s <= 0 {
return 1 // sévérité absente : traitée comme une erreur
}
return s
}
sort.SliceStable(diags, func(i, j int) bool { return rank(diags[i].Severity) < rank(diags[j].Severity) })
nErr, nWarn := 0, 0
for _, d := range diags {
switch rank(d.Severity) {
case 1:
nErr++
case 2:
nWarn++
}
}
total := len(diags)
if len(diags) > lspMaxDiags {
diags = diags[:lspMaxDiags]
}
var b strings.Builder
fmt.Fprintf(&b, "%d erreur(s), %d avertissement(s)", nErr, nWarn)
if total > len(diags) {
fmt.Fprintf(&b, " — %d premiers sur %d", len(diags), total)
}
b.WriteString("\n")
for _, d := range diags {
sev := "info"
switch d.Severity {