fix(dispatcher): send a lone Enter after the task paste to submit it
Multi-line task bodies arrived in Claude Code as "[Pasted text #N +M lines]" and sat in the input buffer forever — the trailing Enter that SendKeys appends to the paste is consumed as a newline inside the paste, not as a submit. Observed live on ccl-auto-11 (secumon) and ccl-auto-12 (secuops): prompt visible, agent idle. - tmux.Client grows a SendEnter(session) method. ExecClient runs `tmux send-keys -t <sess> Enter` (no preceding text), which Claude's TUI accepts as the explicit submit action after a paste. - Dispatcher: after SendKeys(msg), sleep 500ms for the paste to register, then SendEnter. Same sequence a human would perform. - Five mockTmux implementations updated (quota, dispatcher, switcher, lifecycle, watcher tests). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eb6b74c547
commit
6b109ed1bc
8 changed files with 72 additions and 3 deletions
33
VERSION.md
33
VERSION.md
|
|
@ -1,4 +1,35 @@
|
||||||
# Version actuelle : 0.3.1
|
# Version actuelle : 0.3.2
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-04-15
|
||||||
|
**Type:** Patch — Double-Enter pour soumettre les prompts multi-lignes
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **Les tâches dispatchées restaient coincées dans le buffer d'entrée Claude**.
|
||||||
|
Le message s'affichait comme `[Pasted text #N +M lines]` et Claude attendait
|
||||||
|
indéfiniment un Enter explicite. Constaté sur `ccl-auto-11` (tâche secumon)
|
||||||
|
et `ccl-auto-12` (tâche secuops) : le prompt était bien envoyé mais jamais
|
||||||
|
soumis, l'agent restait au prompt vide.
|
||||||
|
- Cause : `SendKeys` envoie `tmux send-keys -t <sess> <text> Enter` mais quand
|
||||||
|
`text` contient des `\n`, Claude Code détecte un paste et **absorbe le Enter
|
||||||
|
final** comme nouvelle ligne du paste. Aucun submit.
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- `tmux.Client.SendEnter(session)` : envoie un Enter isolé.
|
||||||
|
Implémentation `ExecClient.SendEnter` = `tmux send-keys -t <sess> Enter`.
|
||||||
|
- Dispatcher : après `SendKeys(msg)`, `time.Sleep(500ms)` puis `SendEnter()`
|
||||||
|
pour soumettre le paste.
|
||||||
|
- Mocks mis à jour dans 5 fichiers de test (quota, dispatcher, switcher,
|
||||||
|
lifecycle, watcher).
|
||||||
|
|
||||||
|
### Tests effectués
|
||||||
|
- ✅ `go test ./...` full suite (incluant dispatcher)
|
||||||
|
- ✅ Sessions ccl-auto-11 et ccl-auto-12 débloquées manuellement après Enter,
|
||||||
|
travail en cours depuis
|
||||||
|
|
||||||
|
### Fichiers modifiés
|
||||||
|
- `internal/tmux/client.go` — interface + ExecClient.SendEnter
|
||||||
|
- `internal/dispatcher/dispatcher.go` — submit après paste
|
||||||
|
- 5 fichiers `*_test.go` — mocks étendus
|
||||||
|
|
||||||
## [0.3.1] - 2026-04-15
|
## [0.3.1] - 2026-04-15
|
||||||
**Type:** Patch — `start_index` pour faire coexister pool manuel et pool auto
|
**Type:** Patch — `start_index` pour faire coexister pool manuel et pool auto
|
||||||
|
|
|
||||||
|
|
@ -234,11 +234,19 @@ func (d *Dispatcher) launchAgent(session, projectDir, taskFile string) error {
|
||||||
return fmt.Errorf("claude not ready in %q after %v", session, promptTimeout)
|
return fmt.Errorf("claude not ready in %q after %v", session, promptTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the task message.
|
// Send the task message. Multi-line task bodies render in Claude's TUI
|
||||||
|
// as "[Pasted text #N +M lines]" — the trailing Enter that SendKeys
|
||||||
|
// appends is consumed as part of the paste, so the message would stay
|
||||||
|
// unsubmitted in the input buffer forever. Wait for the paste to
|
||||||
|
// register, then send a lone Enter to actually submit.
|
||||||
msg := buildTaskMessage(body, taskFile)
|
msg := buildTaskMessage(body, taskFile)
|
||||||
if err := d.tmux.SendKeys(session, msg); err != nil {
|
if err := d.tmux.SendKeys(session, msg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if err := d.tmux.SendEnter(session); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
d.state.SetWorking(session, filepath.Base(taskFile))
|
d.state.SetWorking(session, filepath.Base(taskFile))
|
||||||
d.logger.Printf("[dispatcher] DISPATCHED session=%q task=%s model=%s",
|
d.logger.Printf("[dispatcher] DISPATCHED session=%q task=%s model=%s",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ func (m *mockTmux) SendKeys(_, keys string) error {
|
||||||
m.sentKeys = append(m.sentKeys, keys)
|
m.sentKeys = append(m.sentKeys, keys)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (m *mockTmux) SendEnter(_ string) error {
|
||||||
|
m.sentKeys = append(m.sentKeys, "<ENTER>")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
||||||
return m.paneOutput[session], nil
|
return m.paneOutput[session], nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ func (m *mockTmux) SendKeys(session, keys string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTmux) SendEnter(session string) error {
|
||||||
|
m.sendKeysCalls = append(m.sendKeysCalls, session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockTmux) CapturePaneTail(session string, lines int) (string, error) {
|
func (m *mockTmux) CapturePaneTail(session string, lines int) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ func (m *mockTmux) HasSession(name string) bool { return m.sessions[name]
|
||||||
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
|
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
|
||||||
func (m *mockTmux) KillSession(_ string) error { return nil }
|
func (m *mockTmux) KillSession(_ string) error { return nil }
|
||||||
func (m *mockTmux) SendKeys(_, _ string) error { return nil }
|
func (m *mockTmux) SendKeys(_, _ string) error { return nil }
|
||||||
|
func (m *mockTmux) SendEnter(_ string) error { return nil }
|
||||||
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
||||||
return m.paneOutput[session], nil
|
return m.paneOutput[session], nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ func (m *mockTmux) SendKeys(session, keys string) error {
|
||||||
m.sendKeyCalls = append(m.sendKeyCalls, session+":"+keys)
|
m.sendKeyCalls = append(m.sendKeyCalls, session+":"+keys)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (m *mockTmux) SendEnter(session string) error {
|
||||||
|
m.sendKeyCalls = append(m.sendKeyCalls, session+":<ENTER>")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
||||||
return m.paneOutput[session], nil
|
return m.paneOutput[session], nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,14 @@ type Client interface {
|
||||||
CreateSession(name, workdir string) error
|
CreateSession(name, workdir string) error
|
||||||
// KillSession destroys the named session.
|
// KillSession destroys the named session.
|
||||||
KillSession(name string) error
|
KillSession(name string) error
|
||||||
// SendKeys sends key strokes to the first window of a session.
|
// SendKeys sends key strokes to the first window of a session, terminated
|
||||||
|
// by a single Enter. Note: when keys contains newlines, Claude Code's
|
||||||
|
// TUI treats the whole block as a paste and the trailing Enter is
|
||||||
|
// consumed as part of it — call SendEnter afterwards to submit.
|
||||||
SendKeys(session, keys string) error
|
SendKeys(session, keys string) error
|
||||||
|
// SendEnter sends a lone Enter keypress. Used to submit a multi-line
|
||||||
|
// paste in Claude Code after SendKeys.
|
||||||
|
SendEnter(session string) error
|
||||||
// CapturePaneTail returns the last n lines from the session's active pane.
|
// CapturePaneTail returns the last n lines from the session's active pane.
|
||||||
CapturePaneTail(session string, lines int) (string, error)
|
CapturePaneTail(session string, lines int) (string, error)
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +84,15 @@ func (c *ExecClient) SendKeys(session, keys string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendEnter sends a lone Enter keypress to the session's first pane.
|
||||||
|
func (c *ExecClient) SendEnter(session string) error {
|
||||||
|
out, err := run("send-keys", "-t", session, "Enter")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tmux send-keys Enter %q: %w (%s)", session, err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CapturePaneTail captures the last n lines from the session's active pane.
|
// CapturePaneTail captures the last n lines from the session's active pane.
|
||||||
func (c *ExecClient) CapturePaneTail(session string, lines int) (string, error) {
|
func (c *ExecClient) CapturePaneTail(session string, lines int) (string, error) {
|
||||||
out, err := run("capture-pane", "-p", "-t", session,
|
out, err := run("capture-pane", "-p", "-t", session,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ func (m *mockTmux) HasSession(name string) bool { return m.sessions
|
||||||
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
|
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
|
||||||
func (m *mockTmux) KillSession(_ string) error { return nil }
|
func (m *mockTmux) KillSession(_ string) error { return nil }
|
||||||
func (m *mockTmux) SendKeys(_, keys string) error { m.sentKeys = append(m.sentKeys, keys); return nil }
|
func (m *mockTmux) SendKeys(_, keys string) error { m.sentKeys = append(m.sentKeys, keys); return nil }
|
||||||
|
func (m *mockTmux) SendEnter(_ string) error { m.sentKeys = append(m.sentKeys, "<ENTER>"); return nil }
|
||||||
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
||||||
return m.paneOutput[session], nil
|
return m.paneOutput[session], nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue