fix: deliver long CLI passthrough prompts intact (#3894)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Dmitry Koryakov
2026-09-24 15:45:00 +02:00
committed by GitHub
parent d602745281
commit 5cb908e4ad
28 changed files with 1129 additions and 143 deletions
+10 -8
View File
@@ -399,17 +399,19 @@ type PassthroughConfig struct {
// and when routing chat-compose messages to the PTY. "\r" for most TUIs. // and when routing chat-compose messages to the PTY. "\r" for most TUIs.
// Empty inherits DefaultPassthroughSubmitSequence at PTY write sites. // Empty inherits DefaultPassthroughSubmitSequence at PTY write sites.
SubmitSequence string SubmitSequence string
// DisableBracketedPaste sends prompt bytes verbatim (plus SubmitSequence). // DisableBracketedPaste sends the prompt body without ESC[200~…ESC[201~
// Claude Code enables bracketed-paste *mode* (?2004h) in its Ink TUI; injecting // delimiters. The planner then paces the body in writes that each fit within
// ESC[200~…ESC[201~ delimiters breaks input (nothing appears in the prompt). // one terminal read, because a TUI can drop whole reads of a larger unframed
// burst. Set it only for a TUI that does not accept bracketed-paste input;
// framed bodies arrive whole at any length.
DisableBracketedPaste bool DisableBracketedPaste bool
// SubmitDelay is the wait inserted before each non-first chunk when writing the // SubmitDelay is the wait inserted before the separate submit chunk when
// prompt+submit sequence to PTY stdin. Ink-based TUIs (Claude Code) detect a // writing a prompt to PTY stdin. Ink-based TUIs (Claude Code) detect a
// "paste burst" when many stdin bytes arrive in one read and absorb the // "paste burst" when many stdin bytes arrive in one read and absorb the
// trailing \r into the pasted content instead of dispatching it as Enter. // trailing \r into the pasted content instead of dispatching it as Enter.
// Splitting the prompt body from the submit byte with a small delay forces the // Writing the submit byte on its own after a small delay makes it arrive as
// submit to arrive as a discrete keystroke. 0 disables (other TUIs handle one // a discrete keystroke. 0 appends the submit sequence to the final body
// atomic write fine). // write (other TUIs handle prompt and submit in one read).
SubmitDelay time.Duration SubmitDelay time.Duration
} }
@@ -52,24 +52,25 @@ func NewClaudeACP() *ClaudeACP {
StandardPassthrough: StandardPassthrough{ StandardPassthrough: StandardPassthrough{
PermSettings: claudeACPPermSettings, PermSettings: claudeACPPermSettings,
Cfg: PassthroughConfig{ Cfg: PassthroughConfig{
Supported: true, Supported: true,
Label: "CLI Passthrough", Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface", Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code"), PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code"),
ModelFlag: NewParam("--model", "{model}"), ModelFlag: NewParam("--model", "{model}"),
IdleTimeout: 3 * time.Second, IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes, BufferMaxBytes: DefaultBufferMaxBytes,
ResumeFlag: NewParam("-c"), ResumeFlag: NewParam("-c"),
SessionResumeFlag: NewParam("--resume"), SessionResumeFlag: NewParam("--resume"),
MCPStrategy: mcpconfig.ClaudeStrategy{}, MCPStrategy: mcpconfig.ClaudeStrategy{},
AutoInjectPrompt: true, AutoInjectPrompt: true,
SubmitSequence: "\r", SubmitSequence: "\r",
DisableBracketedPaste: true,
// Claude Code's Ink TUI coalesces multi-byte stdin reads into a // Claude Code's Ink TUI coalesces multi-byte stdin reads into a
// paste burst, absorbing trailing "\r" into the input rather than // paste burst, absorbing a trailing "\r" into the input rather
// dispatching Enter. A short delay before the submit byte forces // than dispatching Enter. A short delay before the submit byte
// it to arrive as a discrete keystroke. 150ms is just over Ink's // makes it arrive as a discrete keystroke; 150ms is just over
// paste-detection window and still feels instant to the user. // Ink's paste-detection window and still feels instant. Because
// the submit byte is its own write, the body travels as a
// bracketed paste, which the TUI absorbs whole at any length.
SubmitDelay: 150 * time.Millisecond, SubmitDelay: 150 * time.Millisecond,
}, },
}, },
@@ -3,18 +3,33 @@ package agents
import ( import (
"strings" "strings"
"time" "time"
"unicode/utf8"
) )
const ( const (
bracketedPasteStart = "\x1b[200~" bracketedPasteStart = "\x1b[200~"
bracketedPasteEnd = "\x1b[201~" bracketedPasteEnd = "\x1b[201~"
// passthroughRawSafeWriteBytes caps one unframed PTY write below the host's
// single-read capacity (1022 bytes on macOS). A TUI can drop whole reads of
// an unframed burst without reporting it, so an unframed write never exceeds
// one read. Larger bodies are framed as a bracketed paste, which the TUI
// absorbs in one piece at any length.
passthroughRawSafeWriteBytes = 400
// passthroughRawWriteInterval spaces consecutive unframed writes to give the
// TUI time to drain one read before the next arrives. PTY writes provide no
// acknowledgement from the receiving TUI, so this is an empirical pacing
// policy rather than a delivery receipt.
passthroughRawWriteInterval = 30 * time.Millisecond
) )
// PassthroughStdinChunk is one PTY stdin write planned for a passthrough prompt. // PassthroughStdinChunk is one PTY stdin write planned for a passthrough prompt.
// DelayBefore is sleep applied before writing this chunk; it is zero on the first // DelayBefore is the pause applied before writing this chunk. The first chunk
// chunk and inherits PassthroughConfig.SubmitDelay on subsequent chunks. Splitting // carries none; continuation body chunks on the unframed path carry
// the prompt body from the submit byte with a delay defeats Ink-style paste-burst // passthroughRawWriteInterval; a separate submit chunk carries
// detection in TUIs like Claude Code (see PassthroughConfig.SubmitDelay). // PassthroughConfig.SubmitDelay so the submit byte arrives as a discrete keystroke
// rather than being absorbed into an Ink-style paste burst.
type PassthroughStdinChunk struct { type PassthroughStdinChunk struct {
Data string Data string
DelayBefore time.Duration DelayBefore time.Duration
@@ -26,35 +41,101 @@ func PassthroughSubmitSequence(cfg PassthroughConfig) string {
return EffectiveSubmitSequence(cfg.SubmitSequence) return EffectiveSubmitSequence(cfg.SubmitSequence)
} }
// BuildPassthroughPayload assembles the bytes for one atomic PTY stdin write. // stripPasteMarkers removes bracketed-paste delimiters from a prompt before it
// Multi-line prompts use bracketed paste so embedded newlines are not treated as // is delivered. A marker carried inside the body would end the paste early and
// premature Enter presses, unless DisableBracketedPaste is set (Claude Code). // let the remainder reach the TUI as terminal input outside the prompt, which
func BuildPassthroughPayload(prompt string, cfg PassthroughConfig) string { // for a CLI agent means unintended commands.
submit := PassthroughSubmitSequence(cfg) //
if cfg.DisableBracketedPaste || !strings.Contains(prompt, "\n") { // Replace until stable: a single pass can be evaded by nesting a marker inside
return prompt + submit // itself (e.g. "\x1b[2\x1b[201~01~" collapses to a live marker after one pass).
func stripPasteMarkers(value string) string {
for strings.Contains(value, bracketedPasteStart) || strings.Contains(value, bracketedPasteEnd) {
value = strings.ReplaceAll(value, bracketedPasteStart, "")
value = strings.ReplaceAll(value, bracketedPasteEnd, "")
} }
return bracketedPasteStart + prompt + bracketedPasteEnd + submit return value
}
// framePassthroughBody reports whether the body travels as a bracketed paste.
// Framing is what lets a body outgrow the host's single-read capacity: the TUI
// absorbs a delimited paste in one piece, while an equally large unframed burst
// loses whole reads. A short single-line body is written unframed.
func framePassthroughBody(prompt string, cfg PassthroughConfig) bool {
if cfg.DisableBracketedPaste {
return false
}
return strings.Contains(prompt, "\n") || len(prompt) > passthroughRawSafeWriteBytes
}
// planBodyChunks plans the PTY writes that carry the prompt body, excluding the
// submit sequence. A framed body is one write at any length; an unframed body is
// paced so no single write exceeds what the TUI can absorb in one read.
func planBodyChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk {
if framePassthroughBody(prompt, cfg) {
body := stripPasteMarkers(prompt)
return []PassthroughStdinChunk{{Data: bracketedPasteStart + body + bracketedPasteEnd}}
}
return paceRawBody(prompt)
}
// paceRawBody splits an unframed body into writes within the raw-safe size,
// cutting on rune boundaries so no write ends inside a multi-byte character.
// Every continuation carries the inter-write delay; the first carries none.
func paceRawBody(body string) []PassthroughStdinChunk {
if len(body) <= passthroughRawSafeWriteBytes {
return []PassthroughStdinChunk{{Data: body}}
}
var chunks []PassthroughStdinChunk
for len(body) > 0 {
cut := passthroughRawSafeWriteBytes
if cut >= len(body) {
cut = len(body)
} else {
for cut > 0 && !utf8.RuneStart(body[cut]) {
cut--
}
if cut == 0 {
// Not valid UTF-8; fall back to the byte budget rather than
// looping forever on a prompt we cannot split cleanly.
cut = passthroughRawSafeWriteBytes
}
}
var delay time.Duration
if len(chunks) > 0 {
delay = passthroughRawWriteInterval
}
chunks = append(chunks, PassthroughStdinChunk{Data: body[:cut], DelayBefore: delay})
body = body[cut:]
}
return chunks
}
// BuildPassthroughPayload assembles the bytes for one atomic PTY stdin write.
// The body is framed as a bracketed paste unless the agent cannot accept the
// delimiters, so embedded newlines are not treated as premature Enter presses
// and a long body is not split across terminal reads the TUI can drop.
func BuildPassthroughPayload(prompt string, cfg PassthroughConfig) string {
if framePassthroughBody(prompt, cfg) {
body := stripPasteMarkers(prompt)
return bracketedPasteStart + body + bracketedPasteEnd + PassthroughSubmitSequence(cfg)
}
return prompt + PassthroughSubmitSequence(cfg)
} }
// PlanPassthroughStdinChunks plans PTY stdin writes for a passthrough prompt. // PlanPassthroughStdinChunks plans PTY stdin writes for a passthrough prompt.
// When SubmitDelay > 0 (Claude path) the prompt body and submit byte are emitted // When SubmitDelay > 0 (Claude path) the submit byte is emitted as its own chunk
// as two chunks with DelayBefore set on the submit chunk so it arrives as a // with DelayBefore set, so it arrives as a discrete keystroke rather than
// discrete keystroke. Otherwise a single atomic write is returned, preserving // trailing bytes of the body. Otherwise the submit sequence rides on the final
// today's behavior for TUIs that handle prompt+submit in one read (Cursor, Codex, // body write, preserving the single-write behavior for TUIs that handle
// OpenCode). // prompt+submit in one read (Cursor, Codex, OpenCode).
func PlanPassthroughStdinChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk { func PlanPassthroughStdinChunks(prompt string, cfg PassthroughConfig) []PassthroughStdinChunk {
chunks := planBodyChunks(prompt, cfg)
submit := PassthroughSubmitSequence(cfg)
if cfg.SubmitDelay > 0 { if cfg.SubmitDelay > 0 {
body := prompt return append(chunks, PassthroughStdinChunk{Data: submit, DelayBefore: cfg.SubmitDelay})
if !cfg.DisableBracketedPaste && strings.Contains(prompt, "\n") {
body = bracketedPasteStart + prompt + bracketedPasteEnd
}
return []PassthroughStdinChunk{
{Data: body},
{Data: PassthroughSubmitSequence(cfg), DelayBefore: cfg.SubmitDelay},
}
} }
return []PassthroughStdinChunk{{Data: BuildPassthroughPayload(prompt, cfg)}} chunks[len(chunks)-1].Data += submit
return chunks
} }
// PlanPassthroughStdinWrites is the legacy string-only view of PlanPassthroughStdinChunks. // PlanPassthroughStdinWrites is the legacy string-only view of PlanPassthroughStdinChunks.
@@ -1,8 +1,10 @@
package agents package agents
import ( import (
"strings"
"testing" "testing"
"time" "time"
"unicode/utf8"
) )
func TestPlanPassthroughStdinWrites_SingleAtomicWrite(t *testing.T) { func TestPlanPassthroughStdinWrites_SingleAtomicWrite(t *testing.T) {
@@ -26,22 +28,26 @@ func TestPlanPassthroughStdinWrites_SingleLine(t *testing.T) {
} }
// Claude's PassthroughConfig sets SubmitDelay so the submit byte arrives as a // Claude's PassthroughConfig sets SubmitDelay so the submit byte arrives as a
// discrete keystroke (defeats Ink's paste-burst detection). Single-line and // discrete keystroke. The body is one chunk and the submit byte another; a
// multi-line prompts both emit body+submit as two chunks; the submit chunk // short single-line prompt keeps its exact bytes, a multi-line prompt is framed
// carries DelayBefore = SubmitDelay. // as a bracketed paste so the TUI absorbs it as one input.
func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) { func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) {
cfg := NewClaudeACP().PassthroughConfig() cfg := NewClaudeACP().PassthroughConfig()
if cfg.SubmitDelay <= 0 { if cfg.SubmitDelay <= 0 {
t.Fatalf("Claude config must set SubmitDelay > 0 for paste-burst workaround, got %v", cfg.SubmitDelay) t.Fatalf("Claude config must set SubmitDelay > 0 for paste-burst workaround, got %v", cfg.SubmitDelay)
} }
for _, prompt := range []string{"hello", "### Review Comments\n\n> fix"} { cases := map[string]string{
"hello": "hello",
"### Review Comments\n\n> fix": bracketedPasteStart + "### Review Comments\n\n> fix" + bracketedPasteEnd,
}
for prompt, wantBody := range cases {
chunks := PlanPassthroughStdinChunks(prompt, cfg) chunks := PlanPassthroughStdinChunks(prompt, cfg)
if len(chunks) != 2 { if len(chunks) != 2 {
t.Fatalf("prompt %q: got %d chunks, want 2 (body, submit): %#v", prompt, len(chunks), chunks) t.Fatalf("prompt %q: got %d chunks, want 2 (body, submit): %#v", prompt, len(chunks), chunks)
} }
if chunks[0].Data != prompt { if chunks[0].Data != wantBody {
t.Errorf("prompt %q: body chunk = %q, want verbatim prompt", prompt, chunks[0].Data) t.Errorf("prompt %q: body chunk = %q, want %q", prompt, chunks[0].Data, wantBody)
} }
if chunks[0].DelayBefore != 0 { if chunks[0].DelayBefore != 0 {
t.Errorf("prompt %q: body chunk DelayBefore = %v, want 0", prompt, chunks[0].DelayBefore) t.Errorf("prompt %q: body chunk DelayBefore = %v, want 0", prompt, chunks[0].DelayBefore)
@@ -55,6 +61,86 @@ func TestPlanPassthroughStdinChunks_ClaudeSplitsSubmit(t *testing.T) {
} }
} }
// A body larger than one terminal read must travel as one framed write, with the
// prompt recoverable byte-for-byte from between the markers.
func TestPlanPassthroughStdinChunks_FramesBodyLargerThanOneRead(t *testing.T) {
cfg := NewClaudeACP().PassthroughConfig()
prompt := strings.Repeat("строка контекста для агента\n", 400)
if len(prompt) < 8000 {
t.Fatalf("fixture too small to exercise multi-read delivery: %d bytes", len(prompt))
}
chunks := PlanPassthroughStdinChunks(prompt, cfg)
if len(chunks) != 2 {
t.Fatalf("got %d chunks, want 2 (framed body, submit): lengths %v", len(chunks), chunkLengths(chunks))
}
body := chunks[0].Data
if !strings.HasPrefix(body, bracketedPasteStart) || !strings.HasSuffix(body, bracketedPasteEnd) {
t.Fatalf("body is not bracketed: prefix=%q suffix=%q", body[:6], body[len(body)-6:])
}
inner := strings.TrimSuffix(strings.TrimPrefix(body, bracketedPasteStart), bracketedPasteEnd)
if inner != prompt {
t.Errorf("framed body lost content: got %d bytes, want %d", len(inner), len(prompt))
}
}
// A single-line prompt past the raw-safe write size is just as lossy unframed,
// so length alone must trigger framing.
func TestPlanPassthroughStdinChunks_FramesLongSingleLine(t *testing.T) {
cfg := NewClaudeACP().PassthroughConfig()
prompt := strings.Repeat("x", passthroughRawSafeWriteBytes+1)
body := PlanPassthroughStdinChunks(prompt, cfg)[0].Data
if !strings.HasPrefix(body, bracketedPasteStart) {
t.Fatalf("long single-line body was not framed: %q...", body[:20])
}
}
// A marker inside the prompt would end the paste early and let the remainder
// reach the TUI as terminal input outside the prompt.
func TestPlanPassthroughStdinChunks_NeutralizesFramingTerminators(t *testing.T) {
cfg := NewClaudeACP().PassthroughConfig()
prompt := "before\n" + bracketedPasteEnd + "rm -rf /\n" + bracketedPasteStart + "after"
body := PlanPassthroughStdinChunks(prompt, cfg)[0].Data
if strings.Count(body, bracketedPasteStart) != 1 || strings.Count(body, bracketedPasteEnd) != 1 {
t.Fatalf("markers from the prompt survived framing: %q", body)
}
inner := strings.TrimSuffix(strings.TrimPrefix(body, bracketedPasteStart), bracketedPasteEnd)
if strings.Contains(inner, "\x1b[") {
t.Errorf("escape sequence left inside the framed body: %q", inner)
}
for _, want := range []string{"before", "rm -rf /", "after"} {
if !strings.Contains(inner, want) {
t.Errorf("neutralization dropped prompt text %q from %q", want, inner)
}
}
}
func TestPlanPassthroughStdinChunks_UnframedPreservesFramingMarkers(t *testing.T) {
cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true}
prompt := "literal " + bracketedPasteStart + " marker " + bracketedPasteEnd
chunks := PlanPassthroughStdinChunks(prompt, cfg)
if len(chunks) != 1 {
t.Fatalf("got %d chunks, want one atomic write: %#v", len(chunks), chunks)
}
if got, want := chunks[0].Data, prompt+"\r"; got != want {
t.Errorf("unframed payload = %q, want %q", got, want)
}
if got := BuildPassthroughPayload(prompt, cfg); got != prompt+"\r" {
t.Errorf("BuildPassthroughPayload = %q, want literal markers preserved", got)
}
}
func chunkLengths(chunks []PassthroughStdinChunk) []int {
out := make([]int, len(chunks))
for i, c := range chunks {
out[i] = len(c.Data)
}
return out
}
// Non-Claude TUIs (SubmitDelay == 0) keep the single-atomic-write semantics so we // Non-Claude TUIs (SubmitDelay == 0) keep the single-atomic-write semantics so we
// don't accidentally regress Cursor/Codex/OpenCode by splitting their submit. // don't accidentally regress Cursor/Codex/OpenCode by splitting their submit.
func TestPlanPassthroughStdinChunks_AtomicWhenNoDelay(t *testing.T) { func TestPlanPassthroughStdinChunks_AtomicWhenNoDelay(t *testing.T) {
@@ -71,6 +157,72 @@ func TestPlanPassthroughStdinChunks_AtomicWhenNoDelay(t *testing.T) {
} }
} }
// An agent that cannot accept paste framing must still never receive a burst
// larger than one terminal read, so its body is written in paced pieces.
func TestPlanPassthroughStdinChunks_PacesUnframedBody(t *testing.T) {
cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true, SubmitDelay: 150 * time.Millisecond}
prompt := strings.Repeat("unframed body line\n", 200)
chunks := PlanPassthroughStdinChunks(prompt, cfg)
if len(chunks) < 3 {
t.Fatalf("got %d chunks for a %d-byte body, want it paced: %v", len(chunks), len(prompt), chunkLengths(chunks))
}
var body strings.Builder
for i, chunk := range chunks[:len(chunks)-1] {
if len(chunk.Data) > passthroughRawSafeWriteBytes {
t.Errorf("body chunk %d is %d bytes, want at most %d", i, len(chunk.Data), passthroughRawSafeWriteBytes)
}
wantDelay := passthroughRawWriteInterval
if i == 0 {
wantDelay = 0
}
if chunk.DelayBefore != wantDelay {
t.Errorf("body chunk %d DelayBefore = %v, want %v", i, chunk.DelayBefore, wantDelay)
}
body.WriteString(chunk.Data)
}
if body.String() != prompt {
t.Errorf("paced body lost content: got %d bytes, want %d", body.Len(), len(prompt))
}
submit := chunks[len(chunks)-1]
if submit.Data != "\r" || submit.DelayBefore != cfg.SubmitDelay {
t.Errorf("submit chunk = %q after %v, want %q after %v", submit.Data, submit.DelayBefore, "\r", cfg.SubmitDelay)
}
}
// Splitting by bytes would corrupt non-ASCII prompts.
func TestPlanPassthroughStdinChunks_UnframedSplitsOnRuneBoundaries(t *testing.T) {
cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true, SubmitDelay: 150 * time.Millisecond}
prompt := strings.Repeat("сообщение агенту ", 200)
for i, chunk := range PlanPassthroughStdinChunks(prompt, cfg) {
if !utf8.ValidString(chunk.Data) {
t.Fatalf("chunk %d ends inside a multi-byte character: %q", i, chunk.Data)
}
}
}
// A TUI with no submit delay still gets a paced body; its submit sequence rides
// on the final write so prompt and submit stay one read for short prompts.
func TestPlanPassthroughStdinChunks_PacesUnframedBodyWithoutSubmitDelay(t *testing.T) {
cfg := PassthroughConfig{SubmitSequence: "\r", DisableBracketedPaste: true}
prompt := strings.Repeat("x", passthroughRawSafeWriteBytes*3)
chunks := PlanPassthroughStdinChunks(prompt, cfg)
if len(chunks) != 3 {
t.Fatalf("got %d chunks, want 3 paced writes: %v", len(chunks), chunkLengths(chunks))
}
var joined strings.Builder
for _, chunk := range chunks {
joined.WriteString(chunk.Data)
}
if joined.String() != prompt+"\r" {
t.Errorf("paced atomic path lost content: got %d bytes, want %d", joined.Len(), len(prompt)+1)
}
}
// Sanity: a custom config with SubmitDelay set but no Claude-specific flags // Sanity: a custom config with SubmitDelay set but no Claude-specific flags
// also splits — the field is the lever, not the Claude struct identity. // also splits — the field is the lever, not the Claude struct identity.
func TestPlanPassthroughStdinChunks_SubmitDelayDrivesSplit(t *testing.T) { func TestPlanPassthroughStdinChunks_SubmitDelayDrivesSplit(t *testing.T) {
+18 -16
View File
@@ -36,6 +36,9 @@ type TUIAgentConfig struct {
// an MCP client at all. Resolved from the user's stored strategy key; see // an MCP client at all. Resolved from the user's stored strategy key; see
// mcpconfig.StrategyByKey for why it is chosen rather than inferred. // mcpconfig.StrategyByKey for why it is chosen rather than inferred.
MCPStrategy mcpconfig.PassthroughMCPStrategy MCPStrategy mcpconfig.PassthroughMCPStrategy
// DisableBracketedPaste selects the paced unframed delivery path for a
// terminal that does not accept bracketed-paste delimiters.
DisableBracketedPaste bool
} }
// TUIAgent implements Agent + PassthroughAgent for CLI passthrough TUI tools. // TUIAgent implements Agent + PassthroughAgent for CLI passthrough TUI tools.
@@ -74,24 +77,23 @@ func NewTUIAgent(cfg TUIAgentConfig) *TUIAgent {
a := &TUIAgent{ a := &TUIAgent{
StandardPassthrough: StandardPassthrough{ StandardPassthrough: StandardPassthrough{
Cfg: PassthroughConfig{ Cfg: PassthroughConfig{
Supported: true, Supported: true,
Label: "CLI Passthrough", Label: "CLI Passthrough",
Description: cfg.Desc, Description: cfg.Desc,
PassthroughCmd: NewCommand(cmdArgs...), PassthroughCmd: NewCommand(cmdArgs...),
ModelFlag: cfg.ModelFlag, ModelFlag: cfg.ModelFlag,
IdleTimeout: cfg.IdleTimeout, IdleTimeout: cfg.IdleTimeout,
BufferMaxBytes: cfg.BufferMax, BufferMaxBytes: cfg.BufferMax,
WaitForTerminal: cfg.WaitForTerm, WaitForTerminal: cfg.WaitForTerm,
MCPStrategy: cfg.MCPStrategy, MCPStrategy: cfg.MCPStrategy,
DisableBracketedPaste: cfg.DisableBracketedPaste,
// Ink-based TUIs (Claude Code and similar) coalesce multi-byte // Ink-based TUIs (Claude Code and similar) coalesce multi-byte
// stdin reads into a paste burst, absorbing a trailing "\r" into // stdin reads into a paste burst, absorbing a trailing "\r" into
// the pasted content instead of dispatching Enter, and enable // the pasted content instead of dispatching Enter. The submit
// bracketed-paste mode so ESC[200~…ESC[201~ delimiters break // byte is therefore a separate delayed keystroke, and the body
// input. Send prompt bytes verbatim and split the submit byte // travels as a bracketed paste so it arrives whole at any
// into a discrete keystroke so programmatic PTY prompts submit. // length. Matches the built-in Claude passthrough agent.
// Matches the built-in Claude passthrough agent. SubmitDelay: 150 * time.Millisecond,
DisableBracketedPaste: true,
SubmitDelay: 150 * time.Millisecond,
}, },
}, },
cfg: cfg, cfg: cfg,
@@ -9,12 +9,12 @@ import (
) )
// Custom TUI agents wrap arbitrary CLIs, commonly Ink-based TUIs such as Claude // Custom TUI agents wrap arbitrary CLIs, commonly Ink-based TUIs such as Claude
// Code. Ink coalesces multi-byte stdin reads into a paste burst and absorbs a // Code. Ink absorbs a trailing "\r" into a pasted burst instead of dispatching
// trailing "\r" into the pasted content instead of dispatching Enter, and it // Enter, so the submit byte needs its own delayed write. Bracketed-paste
// enables bracketed-paste mode so ESC[200~…ESC[201~ delimiters break input. // framing stays on: without it a body larger than one terminal read reaches the
// The built-in Claude passthrough agent works around both; custom TUI agents // TUI as a burst it silently drops whole reads from. Custom TUI agents must
// must inherit the same defaults or programmatic PTY prompts (peer messaging, // inherit both defaults or programmatic PTY prompts (peer messaging,
// queued-message drain, workflow auto-start) land in the input box unsubmitted. // queued-message drain, workflow auto-start) arrive truncated or unsubmitted.
func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) { func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
a := NewTUIAgent(TUIAgentConfig{ a := NewTUIAgent(TUIAgentConfig{
AgentID: "custom-tui", AgentID: "custom-tui",
@@ -24,8 +24,8 @@ func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
}) })
pt := a.PassthroughConfig() pt := a.PassthroughConfig()
if !pt.DisableBracketedPaste { if pt.DisableBracketedPaste {
t.Error("DisableBracketedPaste = false, want true (send prompt bytes verbatim; Ink breaks on bracketed-paste delimiters)") t.Error("DisableBracketedPaste = true, want false (framing is what carries a body past one terminal read)")
} }
if pt.SubmitDelay != 150*time.Millisecond { if pt.SubmitDelay != 150*time.Millisecond {
t.Errorf("SubmitDelay = %v, want 150ms (split submit byte into a discrete keystroke so Ink does not absorb it into a paste burst)", pt.SubmitDelay) t.Errorf("SubmitDelay = %v, want 150ms (split submit byte into a discrete keystroke so Ink does not absorb it into a paste burst)", pt.SubmitDelay)
@@ -37,6 +37,9 @@ type CustomTUIAgentSpec struct {
// Protocol selects the runtime kandev drives the command with. Empty means // Protocol selects the runtime kandev drives the command with. Empty means
// terminal passthrough. // terminal passthrough.
Protocol CustomAgentProtocol Protocol CustomAgentProtocol
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
// that do not accept bracketed-paste delimiters.
DisableBracketedPaste bool
} }
// ErrUnknownMCPStrategy is returned when a spec names a strategy key that no // ErrUnknownMCPStrategy is returned when a spec names a strategy key that no
@@ -89,14 +92,15 @@ func buildCustomAgent(spec CustomTUIAgentSpec) (agents.Agent, error) {
return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey) return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey)
} }
return agents.NewTUIAgent(agents.TUIAgentConfig{ return agents.NewTUIAgent(agents.TUIAgentConfig{
AgentID: spec.Slug, AgentID: spec.Slug,
AgentName: spec.Slug, AgentName: spec.Slug,
Command: binary, Command: binary,
Desc: spec.Description, Desc: spec.Description,
Display: spec.DisplayName, Display: spec.DisplayName,
WaitForTerm: true, WaitForTerm: true,
CommandArgs: args, CommandArgs: args,
MCPStrategy: strategy, MCPStrategy: strategy,
DisableBracketedPaste: spec.DisableBracketedPaste,
}), nil }), nil
case CustomAgentProtocolACP: case CustomAgentProtocolACP:
if spec.MCPStrategyKey != mcpconfig.StrategyKeyNone { if spec.MCPStrategyKey != mcpconfig.StrategyKeyNone {
@@ -109,6 +109,33 @@ func TestRegisterCustomTUIAgent_CommandArgs(t *testing.T) {
} }
} }
func TestRegisterCustomTUIAgent_DisableBracketedPaste(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
Slug: "raw-tui",
DisplayName: "Raw TUI",
Command: "raw-tui",
DisableBracketedPaste: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ag, ok := reg.Get("raw-tui")
if !ok {
t.Fatal("expected agent to be registered")
}
pt, ok := ag.(agents.PassthroughAgent)
if !ok {
t.Fatal("custom terminal agent does not implement PassthroughAgent")
}
if !pt.PassthroughConfig().DisableBracketedPaste {
t.Error("DisableBracketedPaste = false, want true")
}
}
func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) { func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) {
log := newTestLogger() log := newTestLogger()
reg := NewRegistry(log) reg := NewRegistry(log)
@@ -1721,8 +1721,12 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio
zap.Error(err)) zap.Error(err))
} }
for _, chunk := range agents.PlanPassthroughStdinChunks(description, pt) { for _, chunk := range agents.PlanPassthroughStdinChunks(description, pt) {
if chunk.DelayBefore > 0 { if err := waitPassthroughChunkDelay(ctx, chunk.DelayBefore); err != nil {
time.Sleep(chunk.DelayBefore) m.logger.Warn("autoInjectInitialPrompt delay interrupted",
zap.String("execution_id", execution.ID),
zap.String("process_id", processID),
zap.Error(err))
return
} }
if err := runner.WriteStdin(processID, chunk.Data); err != nil { if err := runner.WriteStdin(processID, chunk.Data); err != nil {
m.logger.Warn("autoInjectInitialPrompt write failed", m.logger.Warn("autoInjectInitialPrompt write failed",
@@ -1738,6 +1742,20 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio
zap.Int("description_len", len(description))) zap.Int("description_len", len(description)))
} }
func waitPassthroughChunkDelay(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// ResolvePassthroughConfig returns the PassthroughConfig for a session's agent. // ResolvePassthroughConfig returns the PassthroughConfig for a session's agent.
// Used by callers outside this package (e.g. orchestrator) that need the submit // Used by callers outside this package (e.g. orchestrator) that need the submit
// sequence to write to PTY stdin. // sequence to write to PTY stdin.
@@ -38,6 +38,9 @@ type CreateCustomTUIAgentRequest struct {
// Protocol is the runtime kandev drives the command with // Protocol is the runtime kandev drives the command with
// (registry.CustomAgentProtocol*). Empty means terminal passthrough. // (registry.CustomAgentProtocol*). Empty means terminal passthrough.
Protocol string Protocol string
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
// that do not accept bracketed-paste delimiters.
DisableBracketedPaste bool
} }
// validateCustomAgentProtocol rejects a protocol/strategy pair the registry // validateCustomAgentProtocol rejects a protocol/strategy pair the registry
@@ -92,14 +95,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
// Register in the in-memory registry // Register in the in-memory registry
if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{ if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
Slug: slug, Slug: slug,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
Command: req.Command, Command: req.Command,
Description: req.Description, Description: req.Description,
Model: req.Model, Model: req.Model,
CommandArgs: req.CommandArgs, CommandArgs: req.CommandArgs,
MCPStrategyKey: req.MCPStrategy, MCPStrategyKey: req.MCPStrategy,
Protocol: protocol, Protocol: protocol,
DisableBracketedPaste: req.DisableBracketedPaste,
}); regErr != nil { }); regErr != nil {
return nil, fmt.Errorf("failed to register agent: %w", regErr) return nil, fmt.Errorf("failed to register agent: %w", regErr)
} }
@@ -107,14 +111,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
// Persist to DB // Persist to DB
acp := protocol == registry.CustomAgentProtocolACP acp := protocol == registry.CustomAgentProtocolACP
tuiConfig := &models.TUIConfigJSON{ tuiConfig := &models.TUIConfigJSON{
Command: req.Command, Command: req.Command,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
Model: req.Model, Model: req.Model,
Description: req.Description, Description: req.Description,
CommandArgs: req.CommandArgs, CommandArgs: req.CommandArgs,
WaitForTerminal: true, WaitForTerminal: true,
MCPStrategy: req.MCPStrategy, MCPStrategy: req.MCPStrategy,
Protocol: req.Protocol, Protocol: req.Protocol,
DisableBracketedPaste: req.DisableBracketedPaste,
} }
agent := &models.Agent{ agent := &models.Agent{
Name: slug, Name: slug,
@@ -251,14 +256,15 @@ func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error {
// silently miss the other. // silently miss the other.
func CustomAgentSpecFromStored(name string, cfg *models.TUIConfigJSON) registry.CustomTUIAgentSpec { func CustomAgentSpecFromStored(name string, cfg *models.TUIConfigJSON) registry.CustomTUIAgentSpec {
return registry.CustomTUIAgentSpec{ return registry.CustomTUIAgentSpec{
Slug: name, Slug: name,
DisplayName: cfg.DisplayName, DisplayName: cfg.DisplayName,
Command: cfg.Command, Command: cfg.Command,
Description: cfg.Description, Description: cfg.Description,
Model: cfg.Model, Model: cfg.Model,
CommandArgs: cfg.CommandArgs, CommandArgs: cfg.CommandArgs,
MCPStrategyKey: cfg.MCPStrategy, MCPStrategyKey: cfg.MCPStrategy,
Protocol: registry.CustomAgentProtocol(cfg.Protocol), Protocol: registry.CustomAgentProtocol(cfg.Protocol),
DisableBracketedPaste: cfg.DisableBracketedPaste,
} }
} }
@@ -82,6 +82,34 @@ func TestCreateCustomTUIAgent_CommandArgsPersisted(t *testing.T) {
} }
} }
func TestCreateCustomTUIAgent_DisableBracketedPasteReachesRuntimeAndStorage(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Raw TUI",
Command: "raw-tui",
DisableBracketedPaste: true,
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, ok := c.agentRegistry.Get("raw-tui")
if !ok {
t.Fatal("custom agent was not registered")
}
pt, ok := ag.(agents.PassthroughAgent)
if !ok {
t.Fatal("custom agent is not a passthrough agent")
}
if !pt.PassthroughConfig().DisableBracketedPaste {
t.Error("runtime DisableBracketedPaste = false, want true")
}
if !st.byName["raw-tui"].TUIConfig.DisableBracketedPaste {
t.Error("stored DisableBracketedPaste = false, want true")
}
}
// TestCreateCustomTUIAgent_NoCommandArgs keeps the existing behaviour intact // TestCreateCustomTUIAgent_NoCommandArgs keeps the existing behaviour intact
// when the caller omits the field. // when the caller omits the field.
func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) { func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) {
@@ -1218,14 +1218,15 @@ func (c *Controller) toAgentDTO(agent *models.Agent, profiles []*models.AgentPro
} }
if agent.TUIConfig != nil { if agent.TUIConfig != nil {
result.TUIConfig = &dto.TUIConfigDTO{ result.TUIConfig = &dto.TUIConfigDTO{
Command: agent.TUIConfig.Command, Command: agent.TUIConfig.Command,
DisplayName: agent.TUIConfig.DisplayName, DisplayName: agent.TUIConfig.DisplayName,
Model: agent.TUIConfig.Model, Model: agent.TUIConfig.Model,
Description: agent.TUIConfig.Description, Description: agent.TUIConfig.Description,
CommandArgs: agent.TUIConfig.CommandArgs, CommandArgs: agent.TUIConfig.CommandArgs,
WaitForTerminal: agent.TUIConfig.WaitForTerminal, WaitForTerminal: agent.TUIConfig.WaitForTerminal,
MCPStrategy: agent.TUIConfig.MCPStrategy, MCPStrategy: agent.TUIConfig.MCPStrategy,
Protocol: agent.TUIConfig.Protocol, Protocol: agent.TUIConfig.Protocol,
DisableBracketedPaste: agent.TUIConfig.DisableBracketedPaste,
} }
} }
if c.agentRegistry != nil { if c.agentRegistry != nil {
@@ -146,6 +146,9 @@ type TUIConfigDTO struct {
// Protocol is the runtime kandev drives the command with ("" = terminal // Protocol is the runtime kandev drives the command with ("" = terminal
// passthrough, "acp" = ACP on stdin/stdout). // passthrough, "acp" = ACP on stdin/stdout).
Protocol string `json:"protocol,omitempty"` Protocol string `json:"protocol,omitempty"`
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
// that do not accept bracketed-paste delimiters.
DisableBracketedPaste bool `json:"disable_bracketed_paste,omitempty"`
} }
type AgentDTO struct { type AgentDTO struct {
@@ -26,17 +26,21 @@ type createCustomTUIAgentRequest struct {
// Protocol is the runtime kandev drives the command with: empty (the // Protocol is the runtime kandev drives the command with: empty (the
// default) for terminal passthrough, "acp" for ACP on stdin/stdout. // default) for terminal passthrough, "acp" for ACP on stdin/stdout.
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
// that do not accept bracketed-paste delimiters.
DisableBracketedPaste bool `json:"disable_bracketed_paste"`
} }
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest { func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
return controller.CreateCustomTUIAgentRequest{ return controller.CreateCustomTUIAgentRequest{
DisplayName: r.DisplayName, DisplayName: r.DisplayName,
Model: r.Model, Model: r.Model,
Command: r.Command, Command: r.Command,
Description: r.Description, Description: r.Description,
CommandArgs: r.CommandArgs, CommandArgs: r.CommandArgs,
MCPStrategy: r.MCPStrategy, MCPStrategy: r.MCPStrategy,
Protocol: r.Protocol, Protocol: r.Protocol,
DisableBracketedPaste: r.DisableBracketedPaste,
} }
} }
@@ -14,7 +14,8 @@ func TestCreateCustomTUIAgentRequest_MapsCommandArgs(t *testing.T) {
payload := `{ payload := `{
"display_name": "Spaced Args", "display_name": "Spaced Args",
"command": "my-cli", "command": "my-cli",
"command_args": ["--system-prompt", "you are a helpful agent"] "command_args": ["--system-prompt", "you are a helpful agent"],
"disable_bracketed_paste": true
}` }`
var body createCustomTUIAgentRequest var body createCustomTUIAgentRequest
@@ -30,6 +31,9 @@ func TestCreateCustomTUIAgentRequest_MapsCommandArgs(t *testing.T) {
if req.DisplayName != "Spaced Args" || req.Command != "my-cli" { if req.DisplayName != "Spaced Args" || req.Command != "my-cli" {
t.Errorf("unexpected mapping: %+v", req) t.Errorf("unexpected mapping: %+v", req)
} }
if !req.DisableBracketedPaste {
t.Error("DisableBracketedPaste = false, want true")
}
} }
// TestCreateCustomTUIAgentRequest_OmittedCommandArgs keeps the field optional. // TestCreateCustomTUIAgentRequest_OmittedCommandArgs keeps the field optional.
@@ -50,6 +50,9 @@ type TUIConfigJSON struct {
// (registry.CustomAgentProtocol*). Empty — the value every row written // (registry.CustomAgentProtocol*). Empty — the value every row written
// before this field existed decodes to — means terminal passthrough. // before this field existed decodes to — means terminal passthrough.
Protocol string `json:"protocol,omitempty"` Protocol string `json:"protocol,omitempty"`
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
// that do not accept bracketed-paste delimiters.
DisableBracketedPaste bool `json:"disable_bracketed_paste,omitempty"`
} }
type AgentProfile struct { type AgentProfile struct {
@@ -2516,6 +2516,25 @@ func TestDeliverPassthroughPrompt(t *testing.T) {
} }
}) })
t.Run("refuses the prompt when config resolution fails", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
configErr := errors.New("agent definition is unavailable")
agentMgr := &mockAgentManager{
isPassthrough: true,
passthroughConfigErr: configErr,
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
err := svc.writePassthroughPrompt(context.Background(), "s1", strings.Repeat("long prompt ", 100))
if !errors.Is(err, configErr) {
t.Fatalf("writePassthroughPrompt error = %v, want config error", err)
}
if got := len(agentMgr.passthroughStdinCalls); got != 0 {
t.Fatalf("stdin calls after config failure = %d, want 0", got)
}
})
t.Run("cancellation interrupts submit delay", func(t *testing.T) { t.Run("cancellation interrupts submit delay", func(t *testing.T) {
repo := setupTestRepo(t) repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1") seedSession(t, repo, "t1", "s1", "step1")
@@ -5592,8 +5592,8 @@ type passthroughRunningPreparer interface {
// deliverPassthroughPrompt writes a prompt to PTY stdin and marks the session as running. // deliverPassthroughPrompt writes a prompt to PTY stdin and marks the session as running.
// Uses the per-agent PlanPassthroughStdinChunks so Claude's inter-chunk SubmitDelay is // Uses the per-agent PlanPassthroughStdinChunks so Claude's inter-chunk SubmitDelay is
// honored here too (queued / workflow-auto-start path); other agents stay on the single // honored here too (queued / workflow-auto-start path); other agents stay on the single
// atomic write. Falls back to the simple "\r" append if config resolution fails so a // atomic write. Config resolution is required: an unknown agent contract must not fall
// transient lookup error never silently swallows the prompt. // back to one unframed write, because a long prompt can be silently truncated.
// //
// Callers that already hold the per-session cancellation guard must use // Callers that already hold the per-session cancellation guard must use
// PreparePassthroughRunning + writePassthroughPrompt instead (see handleAgentReady); this // PreparePassthroughRunning + writePassthroughPrompt instead (see handleAgentReady); this
@@ -5618,15 +5618,10 @@ func (s *Service) deliverPassthroughPrompt(ctx context.Context, sessionID, conte
func (s *Service) writePassthroughPrompt(ctx context.Context, sessionID, content string) error { func (s *Service) writePassthroughPrompt(ctx context.Context, sessionID, content string) error {
pt, cfgErr := s.agentManager.ResolvePassthroughConfig(ctx, sessionID) pt, cfgErr := s.agentManager.ResolvePassthroughConfig(ctx, sessionID)
if cfgErr != nil { if cfgErr != nil {
s.logger.Warn("failed to resolve passthrough config, falling back to \\r submit", s.logger.Warn("failed to resolve passthrough config; refusing unsafe prompt write",
zap.String("session_id", sessionID), zap.String("session_id", sessionID),
zap.Error(cfgErr)) zap.Error(cfgErr))
} return fmt.Errorf("resolve passthrough config: %w", cfgErr)
if cfgErr != nil {
if err := s.agentManager.WritePassthroughStdin(ctx, sessionID, content+"\r"); err != nil {
return fmt.Errorf("write to passthrough stdin: %w", err)
}
return nil
} }
for _, chunk := range agents.PlanPassthroughStdinChunks(content, pt) { for _, chunk := range agents.PlanPassthroughStdinChunks(content, pt) {
if chunk.DelayBefore > 0 { if chunk.DelayBefore > 0 {
@@ -724,8 +724,8 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session
zap.Error(err)) zap.Error(err))
} }
for _, chunk := range agents.PlanPassthroughStdinChunks(promptWithAttachments, pt) { for _, chunk := range agents.PlanPassthroughStdinChunks(promptWithAttachments, pt) {
if chunk.DelayBefore > 0 { if err := waitPassthroughChunkDelay(ctx, chunk.DelayBefore); err != nil {
time.Sleep(chunk.DelayBefore) return nil, err
} }
if err := e.agentManager.WritePassthroughStdin(ctx, sessionID, chunk.Data); err != nil { if err := e.agentManager.WritePassthroughStdin(ctx, sessionID, chunk.Data); err != nil {
return nil, fmt.Errorf("failed to write to passthrough stdin: %w", err) return nil, fmt.Errorf("failed to write to passthrough stdin: %w", err)
@@ -734,6 +734,20 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session
return &PromptResult{StopReason: stopReasonPassthrough}, nil return &PromptResult{StopReason: stopReasonPassthrough}, nil
} }
func waitPassthroughChunkDelay(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (e *Executor) buildPassthroughPromptWithAttachments(ctx context.Context, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (string, error) { func (e *Executor) buildPassthroughPromptWithAttachments(ctx context.Context, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (string, error) {
if len(attachments) == 0 { if len(attachments) == 0 {
return prompt, nil return prompt, nil
@@ -588,6 +588,37 @@ func TestExecutor_Prompt_PassthroughSubmitDelaySplitsWrites(t *testing.T) {
} }
} }
func TestExecutor_Prompt_PassthroughCancellationStopsDelayedSubmit(t *testing.T) {
repo := newMockRepository()
ctx, cancel := context.WithCancel(context.Background())
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
resolvePassthroughConfigFunc: func(_ context.Context, _ string) (agents.PassthroughConfig, error) {
return agents.PassthroughConfig{
Supported: true,
SubmitSequence: "\r",
DisableBracketedPaste: true,
SubmitDelay: time.Second,
}, nil
},
writePassthroughStdinFunc: func(_ context.Context, _ string, data string) error {
if data == "hello agent" {
cancel()
}
return nil
},
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
exec := newTestExecutor(t, agentManager, repo)
if _, err := exec.Prompt(ctx, "task-1", "sess-1", "hello agent", nil, false); !errors.Is(err, context.Canceled) {
t.Fatalf("Prompt error = %v, want context cancellation", err)
}
if got := len(agentManager.writePassthroughStdinCalls); got != 1 {
t.Fatalf("stdin calls after cancellation = %d, want 1", got)
}
}
func TestExecutor_Prompt_ACPPathUnchanged(t *testing.T) { func TestExecutor_Prompt_ACPPathUnchanged(t *testing.T) {
repo := newMockRepository() repo := newMockRepository()
agentManager := &mockAgentManager{ agentManager := &mockAgentManager{
+1
View File
@@ -347,6 +347,7 @@ export async function createCustomTUIAgent(
description?: string; description?: string;
mcp_strategy?: string; mcp_strategy?: string;
protocol?: string; protocol?: string;
disable_bracketed_paste?: boolean;
}, },
options?: ApiRequestOptions, options?: ApiRequestOptions,
): Promise<Agent> { ): Promise<Agent> {
+2
View File
@@ -47,6 +47,8 @@ export type TUIConfig = {
* calls, models, and modes. * calls, models, and modes.
*/ */
protocol?: CustomAgentProtocol; protocol?: CustomAgentProtocol;
/** Use paced unframed writes for TUIs that reject bracketed-paste markers. */
disable_bracketed_paste?: boolean;
}; };
/** One selectable MCP injection mechanism, served by the backend. */ /** One selectable MCP injection mechanism, served by the backend. */
@@ -0,0 +1,134 @@
---
created: 2026-09-22
status: complete
requirements:
- REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001
system_design:
- ../../specs/cli/system-design/passthrough-prompt-delivery.md
legacy_specs: []
---
# Implementation Plan: Passthrough Prompt Delivery Integrity
## Overview
A prompt delivered to a CLI passthrough session loses whole leading read-sized
blocks of its body. Measured against a live instance, every agent-to-agent
message longer than one read arrived at the agent missing exactly N x 1022
bytes from the front, with no error on any surface. The planner writes the whole
body as one unframed PTY write, so the receiving TUI has to absorb a burst many
times larger than one terminal read and silently drops the reads it cannot take.
Two work orders correct this in the one place that plans PTY writes. The first
restores bracketed-paste framing for TUIs that accept it, which a probe against
the real CLI showed carries 16 KB in a single write without loss. The second
paces the remaining unframed path so a body can never exceed one read per write.
## Scope
### In scope
- Frame passthrough prompt bodies with bracketed paste when the agent accepts
it, keeping the submit keystroke a separate delayed write.
- Neutralize framing terminators inside the body.
- Split unframed bodies into rune-safe writes within the host read budget, with
an inter-write delay.
- Update the agent capability records that currently disable framing, and the
tests that pin the superseded behavior.
### Out of scope
- The structured (non-passthrough) prompt path.
- Operator keystrokes typed into the mirrored terminal view.
- Message wrapping, queueing, delivery mode, and mid-turn steering.
- Reading the terminal back to acknowledge delivery.
- The local hotfix build and install on the operator's instance, which happens
outside the repository and is tracked in the session that requested it.
## Technical approach
`PlanPassthroughStdinChunks` stays the single planner and stays pure. It gains
three rules: neutralize framing terminators in the body; frame the body when the
capability record allows it and the body is multi-line or larger than the
raw-safe write size; otherwise emit the body as consecutive rune-safe writes no
larger than the raw-safe size, each continuation carrying an inter-write delay.
The submit keystroke keeps its own trailing chunk and its existing delay.
Callers already loop over planned chunks and honor `DelayBefore`, so no caller
changes. The raw-safe write size is set below the smallest supported host read
capacity (1022 bytes on macOS); 400 bytes with a 30 ms inter-write delay is the
combination verified against the real CLI.
`claude_acp.go` and `tui_agent.go` stop setting `DisableBracketedPaste`. The
field stays in the capability record as the lever for a TUI that genuinely
cannot accept framing, and work order 02 makes that path safe.
## Tests
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1`: planner tests in
`apps/backend/internal/agent/agents/passthrough_payload_test.go` assert the
concatenated chunk bodies reproduce the prompt exactly, for a body far larger
than one read, on both the framed and the unframed path.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.2`: a framed body is one chunk
carrying start and end markers; an unframed body is consecutive chunks in
order.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.3`: the submit keystroke remains the
final, separate chunk with `DelayBefore` equal to the configured submit delay.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.4`: with `DisableBracketedPaste` set,
no chunk exceeds the raw-safe size and each continuation carries the
inter-write delay.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.5`: a body containing a framing
terminator is neutralized before framing, and the marker does not survive into
a delivered chunk.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.6`: covered by the planner being the
single source for all callers; existing caller tests in
`manager_passthrough_autoinject_test.go` and
`executor_passthrough_prompt_test.go` keep that wiring honest.
- `AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.7`: existing passthrough prompt error
handling in `executor_passthrough_prompt_test.go` already asserts a write
failure surfaces to the caller; work order 02 keeps it passing with several
writes in flight.
## E2E tests
No Playwright scenario is added. The defect lives between the Go planner and a
vendor TUI's stdin handling, which Playwright does not observe. The behavioral
check that matters is the manual TUI probe recorded in work order 01, plus the
post-deploy comparison of stored message text against the agent's own
transcript.
## Work orders
- [x] [Task 01: Frame passthrough prompt body](task-01-frame-passthrough-prompt-body.md) (done)
- [x] [Task 02: Pace unframed passthrough writes](task-02-pace-unframed-passthrough-writes.md) (done)
## Verification results
Both work orders completed. A probe against the real vendor CLI confirmed a
framed 8 KB body written in one PTY write is accepted whole and submitted by the
delayed submit keystroke. `go test ./internal/agent/agents/` and
`./internal/orchestrator/executor/` pass; `go vet` and `gofmt` are clean and the
backend builds. Failures in `./internal/agent/runtime/lifecycle/` (worktree
preparer, Kubernetes prepare-script, and SSH identity-agent tests) are
environment-dependent: the same twelve tests fail on a pristine checkout of the
base branch, with and without this change. `golangci-lint` was not run: it is
not installed on the development machine.
End-to-end on a live instance running the patched `v0.95.0` build, against a
Claude CLI passthrough session: the initial prompt injector wrote 10174 bytes
and the agent's own transcript recorded 10174 bytes, starting at the first byte,
with all 60 line markers present. A `message_task_kandev` message of 9213 bytes,
the path that exposed the defect, arrived byte-for-byte identical to the stored
message, and the agent reported every marker from first to last. Before the
correction, every stored agent message above 5 KB had lost between 1022 and
5110 bytes from its start.
## Risks
- A TUI that does not enable bracketed-paste mode would render the markers as
literal text. Work order 01 confirms the framed body is accepted and submitted
by the real CLI before the capability records change.
- Custom TUI agents inherit the same change as the built-in Claude record. They
share the same TUI family but were not individually probed.
- The unframed path becomes slower in proportion to body size, which is
intentional: correctness over latency for a path with no acknowledgement.
@@ -0,0 +1,123 @@
---
id: "01-frame-passthrough-prompt-body"
title: "Frame passthrough prompt body"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001
acceptance_criteria:
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.2
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.3
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.5
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.6
system_design:
- ../../specs/cli/system-design/passthrough-prompt-delivery.md
---
# Task 01: Frame Passthrough Prompt Body
## Summary
Restore bracketed-paste framing for passthrough agents that accept it, so a
prompt body larger than one terminal read reaches the TUI as one paste instead
of a burst it drops reads from. Neutralize framing terminators inside the body
first, and keep the submit keystroke a separate delayed write.
## In scope
- Add failing planner tests for a body far larger than one read, for framing
markers, and for a body containing a framing terminator.
- Neutralize framing terminators in the body inside the planner.
- Frame the body when the capability record allows framing and the body is
multi-line or larger than the raw-safe write size.
- Stop setting `DisableBracketedPaste` in the built-in Claude capability record
and in the custom TUI agent constructor.
- Update the existing tests that pin the superseded unframed behavior.
## Out of scope
- The unframed delivery path, which work order 02 paces.
- Caller changes: the initial-prompt injector, workflow step handler, and
session prompt path already loop over planned chunks.
- Detecting bracketed-paste support from the terminal output stream.
## Acceptance
- A probe against the real vendor CLI confirms a framed body of at least 16 KB
written in one PTY write reaches the input intact and is submitted by the
delayed submit keystroke. If submission fails, stop and re-plan: the fallback
is the paced path from work order 02 for this agent too.
- Concatenating the planned body chunks reproduces the prompt exactly, with
framing markers only at the outer boundary and none surviving from the body.
- The submit keystroke remains the final separate chunk carrying the configured
submit delay, and small single-line bodies keep their current bytes.
## Verification
```bash
(cd apps/backend && go test ./internal/agent/agents/ -run 'TestPlanPassthroughStdin|TestBuildPassthroughPayload|TestNewTUIAgentDefaultsToInkSafePassthrough|TestClaudeACP' -count=1)
(cd apps/backend && go test ./internal/agent/agents/ ./internal/agent/runtime/lifecycle/ ./internal/orchestrator/executor/ -count=1)
(cd apps/backend && gofmt -l internal/agent/agents)
```
## Files likely touched
- `apps/backend/internal/agent/agents/passthrough_payload.go`
- `apps/backend/internal/agent/agents/passthrough_payload_test.go`
- `apps/backend/internal/agent/agents/claude_acp.go`
- `apps/backend/internal/agent/agents/tui_agent.go`
- `apps/backend/internal/agent/agents/tui_agent_test.go`
- `docs/plans/passthrough-prompt-delivery-integrity/plan.md`
- `docs/plans/passthrough-prompt-delivery-integrity/task-01-frame-passthrough-prompt-body.md`
## Dependencies
None.
## Risks
- `TestNewTUIAgentDefaultsToInkSafePassthrough` and the Claude body-verbatim
assertion in `passthrough_payload_test.go` encode the behavior being replaced.
Rewrite them to assert the new contract rather than deleting the coverage.
- The historical reason for disabling framing was a submit keystroke absorbed
into the paste. The separate delayed submit chunk already addresses it; the
probe in Acceptance is what proves it, not the commit history.
## Parallelism
`sequential`
## Inputs
- `REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001` and its system design.
- Diagnosis evidence: delivered bodies lost exactly N x 1022 bytes from the
front; a framed 16 KB body written once arrived complete in a probe against
the real CLI; an unframed 8 KB body lost roughly half its lines.
- Existing planner, capability records, and caller tests.
## Results
PROBE: An 8 KB body framed as a bracketed paste and written to the real CLI in
one PTY write was accepted whole, and the submit keystroke written 150 ms later
started the turn: the input box cleared and the agent moved to its thinking
state. Framing therefore does not break submission, so the design holds and the
paced fallback stays reserved for agents that refuse framing.
RED: Five behavioral failures. The Claude body chunk arrived unframed for a
multi-line prompt, an 11 KB multi-line body was not bracketed, a single-line
body past the raw-safe size was not bracketed, prompt-embedded paste markers
survived into the delivered body, and the custom TUI agent still disabled
framing.
GREEN: The planner now strips paste markers from the body, frames it when the
agent accepts framing and the body is multi-line or larger than
`passthroughRawSafeWriteBytes`, and keeps the submit byte a separate delayed
chunk. `claude_acp.go` and `tui_agent.go` no longer set
`DisableBracketedPaste`. `go test ./internal/agent/agents/` and
`./internal/orchestrator/executor/` pass. Failures in
`./internal/agent/runtime/lifecycle/` (worktree preparer and SSH identity-agent
tests) reproduce identically on a pristine checkout of the base branch and are
unrelated to this work order.
@@ -0,0 +1,115 @@
---
id: "02-pace-unframed-passthrough-writes"
title: "Pace unframed passthrough writes"
status: done
wave: 2
depends_on:
- "01-frame-passthrough-prompt-body"
plan: "plan.md"
requirements:
- REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001
acceptance_criteria:
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.4
- AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.7
system_design:
- ../../specs/cli/system-design/passthrough-prompt-delivery.md
---
# Task 02: Pace Unframed Passthrough Writes
## Summary
Make the remaining unframed path safe for an agent whose capability record
disables framing. Split the body into rune-safe writes no larger than the
raw-safe size and delay each continuation, so the receiving TUI never has to
absorb more than one terminal read at a time.
## In scope
- Add failing planner tests for an unframed body larger than the raw-safe size,
including a multi-byte body whose split would otherwise fall mid-character.
- Split the unframed body into ordered chunks within the raw-safe size, on rune
boundaries, each continuation carrying the inter-write delay.
- Name the raw-safe size and inter-write delay as constants with the host read
capacity they derive from.
- Keep the submit keystroke the final chunk with its own delay, after the last
body chunk.
- Confirm a write failure mid-body still surfaces to the caller.
## Out of scope
- The framed path from work order 01.
- Changing the capability records again; this path exists for agents that
genuinely cannot accept framing.
- Adaptive sizing or feedback from the terminal output stream.
## Acceptance
- With framing disabled, no planned body chunk exceeds the raw-safe size, the
concatenated chunks reproduce the prompt exactly, and no chunk ends inside a
multi-byte character.
- Every continuation body chunk carries the inter-write delay; the first body
chunk carries none.
- A write error on any chunk aborts the remaining writes and returns to the
caller as a failed delivery.
## Verification
```bash
(cd apps/backend && go test ./internal/agent/agents/ -run 'TestPlanPassthroughStdin' -count=1)
(cd apps/backend && go test ./internal/agent/agents/ ./internal/orchestrator/executor/ -count=1)
(cd apps/backend && gofmt -l internal/agent/agents)
```
## Files likely touched
- `apps/backend/internal/agent/agents/passthrough_payload.go`
- `apps/backend/internal/agent/agents/passthrough_payload_test.go`
- `docs/plans/passthrough-prompt-delivery-integrity/plan.md`
- `docs/plans/passthrough-prompt-delivery-integrity/task-02-pace-unframed-passthrough-writes.md`
## Dependencies
- Work order 01 establishes the framed path and the shared raw-safe size
constant this work order reuses.
## Risks
- Delivery time grows with body size on this path. Keep the constants in one
place with a comment naming the host read capacity, so a future change is a
deliberate trade rather than a guess.
- Splitting by bytes instead of runes would corrupt non-ASCII prompts, which is
exactly the traffic that exposed the original defect.
## Parallelism
`sequential`
## Inputs
- `REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001` and its system design.
- Probe evidence: unframed writes of 400 bytes spaced 30 ms apart delivered a
3 KB body with no loss, while one 8 KB unframed write lost about half of it.
- The planner and tests as left by work order 01.
## Results
RED: A 3.8 KB unframed body still planned as one 3800-byte write, and the
no-submit-delay path planned one 1201-byte write, both far past the raw-safe
size.
GREEN: `paceRawBody` splits an unframed body into writes of at most
`passthroughRawSafeWriteBytes`, cutting on rune boundaries, with
`passthroughRawWriteInterval` before each continuation. The submit sequence
stays its own delayed chunk when the agent sets a submit delay and otherwise
rides on the final body write. The rune-boundary test caught a real off-by-one:
a body whose length is an exact multiple of the write size indexed one byte past
the end. `go test ./internal/agent/agents/` and
`./internal/orchestrator/executor/` pass, `go vet ./internal/agent/agents/...`
is clean, `gofmt -l internal/agent/agents` is empty, and `go build ./...`
succeeds.
Not run: `golangci-lint`, which is not installed on this machine and was not
added. The changed functions stay well inside the documented limits (longest is
28 lines, nesting depth 4).
@@ -76,7 +76,7 @@ When a passthrough session starts for a task that has a description:
No per-agent pattern matchers. The existing idle window is the only readiness signal. If an agent's CLI is unusual enough that an idle window misfires (writes a banner, then waits 5 seconds, then prompts), we make the idle window per-agent-configurable in `PassthroughConfig` (already exists as `IdleTimeout`). No new detection machinery. No per-agent pattern matchers. The existing idle window is the only readiness signal. If an agent's CLI is unusual enough that an idle window misfires (writes a banner, then waits 5 seconds, then prompts), we make the idle window per-agent-configurable in `PassthroughConfig` (already exists as `IdleTimeout`). No new detection machinery.
For the Claude case: `claude_acp.go` retains `AutoInjectPrompt: true` as compatibility metadata, sets `DisableBracketedPaste: true` (Claude Code already enables bracketed-paste *mode* in its Ink TUI — injecting `ESC[200~`…`ESC[201~` delimiters breaks the prompt), and `SubmitViaBackslashEnter: true` (PTY writes: prompt, then `\`, then `\r` per [Claude terminal docs](https://code.claude.com/docs/en/terminal-config)). Ink may still treat programmatic Enter as newline only ([anthropics/claude-code#15553](https://github.com/anthropics/claude-code/issues/15553)) — if auto-submit fails, the user confirms with Enter. Other passthrough-capable agents without a `PromptFlag` use the same idle-based stdin route. For the Claude case: `claude_acp.go` retains `AutoInjectPrompt: true` as compatibility metadata and uses a 150 ms `SubmitDelay`. The prompt body uses bracketed-paste framing, then the submit carriage return is sent as a separate delayed write. This keeps the submit byte out of the Ink paste burst. Custom terminal agents can set `DisableBracketedPaste: true` when they do not accept bracketed-paste delimiters; Kandev then uses bounded, paced unframed writes. The pacing is an empirical fallback because PTY writes do not provide a receipt from the TUI. Other passthrough-capable agents without a `PromptFlag` use the same idle-based stdin route.
### Follow-up prompts via PTY stdin ### Follow-up prompts via PTY stdin
@@ -0,0 +1,96 @@
---
status: draft
system: cli
created: 2026-09-22
owners:
- kandev
---
# Passthrough Prompt Delivery Requirements
## Overview
In CLI passthrough mode Kandev runs a vendor CLI inside a PTY and types prompts
into it, because that CLI exposes no structured prompt channel. Every other
delivery surface hands the agent a framed message; passthrough hands it
keystrokes, so the prompt is only as reliable as the terminal path that carries
it. A host terminal delivers input to the reader in fixed-size reads, and a TUI
that cannot absorb a burst that large drops whole reads without reporting an
error. The operator sees a complete message in Kandev, the agent answers a
truncated one, and nothing in either surface marks the difference.
The CLI system owns this contract because it owns how Kandev drives a vendor CLI
through a PTY and the compatibility behavior that path needs. What is delivered
and when it is delivered stay with their own owners: the tasks system owns the
message API and its delivery modes, and the platform system owns delivery into a
generating turn.
## Terminology
- **CLI passthrough session:** An agent session whose profile sets
`cli_passthrough`, where Kandev runs the vendor CLI as a PTY child and mirrors
that terminal to the operator.
- **Prompt body:** The prompt text Kandev delivers, excluding the submit
keystroke.
- **Submit keystroke:** The byte sequence that makes the receiving TUI start a
turn for the delivered body.
- **Bracketed paste:** The terminal convention in which `ESC[200~` and `ESC[201~`
delimit pasted text so a TUI treats the whole burst as one paste rather than a
stream of keystrokes.
- **Single-read capacity:** The number of bytes a host terminal hands its reader
per read. It is a host constant, not a Kandev setting, and is smaller than a
typical prompt.
## Requirements
### REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001: Passthrough Prompt Delivery Integrity
**Intent:** A prompt that Kandev accepts for a passthrough session must reach
the agent as the same text the operator or calling agent sent. Silent partial
delivery is worse than a failed delivery: the agent acts on a fragment, the
answer looks coherent, and the loss is invisible in every Kandev surface.
**User story:** As an operator running an agent in CLI passthrough mode, I want
every prompt to arrive whole, so that the agent answers what was actually sent.
#### Acceptance criteria
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1:** When Kandev delivers a prompt to
a CLI passthrough session, the receiving agent shall receive the prompt body
in full and unmodified, at any body length.
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.2:** When a prompt body exceeds the
host's single-read capacity, delivery shall preserve body order and shall
present the body to the receiving TUI as one continuous input rather than as
unrelated input events it has to rejoin.
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.3:** When the prompt body has been
delivered, the submit keystroke shall arrive as a discrete input event, so the
receiving TUI starts exactly one turn for that body.
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.4:** When the receiving agent is
configured as unable to accept bracketed-paste framing, delivery shall keep
AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.1 true by pacing the body within the
host's single-read capacity instead of writing one oversized burst.
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.5:** When the prompt body contains
byte sequences that would end the delivery framing early, delivery shall
neutralize those sequences so no part of the body reaches the TUI as terminal
input outside the prompt.
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.6:** When a prompt originates from the
initial task description, an operator composer message, a workflow step, or an
agent-to-agent message, delivery integrity shall be identical, because all
passthrough prompt sources share one delivery contract.
- **AC-CLI-PASSTHROUGH-PROMPT-DELIVERY-001.7:** When delivery of a body cannot
complete, Kandev shall report the failure to the caller rather than leaving a
partially typed prompt as a successful delivery.
## Out of scope
- Prompt transport for non-passthrough sessions, which uses the structured agent
protocol and not a terminal.
- Operator keystrokes typed directly into the mirrored terminal view, which the
terminal input path owns.
- Which prompt is delivered, its wrapping, its queue position, and its delivery
mode, owned by the tasks system.
- Delivery into an already generating turn, owned by the platform system's
mid-turn steering contract.
- How the receiving agent interprets or answers the prompt once it has it.
- Message storage and rendering in Kandev surfaces, owned by the tasks and UI
systems.
@@ -0,0 +1,120 @@
---
status: draft
system: cli
requirements:
- REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001
---
# Passthrough Prompt Delivery System Design
## Purpose and boundaries
This design owns how a prompt that is already selected for a CLI passthrough
session becomes bytes on that session's PTY. It owns the framing of the body,
the size and pacing of the writes, and the separation of the submit keystroke.
It does not own the prompt text, its system wrapper, its queue position, or its
delivery mode; those arrive from the tasks system. It does not own PTY process
startup, terminal mirroring, or operator keystrokes, which belong to the
interactive process runner. It consumes the per-agent passthrough capability
record but does not own the agent catalog.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-CLI-PASSTHROUGH-PROMPT-DELIVERY-001` | [Components and responsibilities](#components-and-responsibilities), [Data and contracts](#data-and-contracts), [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery), [Security](#security) |
## Components and responsibilities
- **Passthrough capability record.** Per-agent configuration that states the
submit sequence, the submit delay, and whether the agent accepts
bracketed-paste framing. It is the only input that varies delivery behavior
between agents; delivery code never branches on an agent identity.
- **Stdin chunk planner.** The single function that converts a prompt and a
capability record into an ordered list of PTY writes. It is pure: no PTY, no
clock, no I/O, so every framing and sizing rule is unit-testable. It is the
only place that knows about framing markers and the host read budget.
- **Delivery callers.** The initial-prompt injector, the workflow step prompt
handler, and the session prompt path each obtain the planned chunks and write
them in order, honoring each chunk's pre-write delay. They add no framing of
their own.
- **PTY writer.** The lifecycle manager's passthrough stdin call and the
interactive runner behind it write one chunk per call to the PTY master and
surface write errors to the caller.
## Data and contracts
- **Planned chunk.** An ordered pair of the bytes to write and the delay to
apply before writing them. The first chunk carries no delay. This type already
exists and is unchanged.
- **Framing markers.** The bracketed-paste start and end sequences. They are
applied to the body only, never to the submit keystroke.
- **Raw-safe write size.** A byte budget for one write on the unframed path,
chosen below the smallest host single-read capacity Kandev supports, so a
receiving TUI never has to absorb more than one read worth of unframed input
at a time. Splits fall on rune boundaries so no write ends mid-character.
- **Inter-chunk delay.** The pause applied before each continuation write on the
unframed path, sized so the receiving TUI drains one read before the next
arrives.
- **Submit delay.** The existing per-agent pause before the submit keystroke,
which keeps that keystroke a discrete input event rather than trailing bytes
of the body.
## Control flow
1. A caller resolves the session's capability record and asks the planner for
the chunks of a prompt.
2. If framing is selected, the planner neutralizes framing terminators inside the body.
3. When the agent accepts bracketed-paste framing and the body is multi-line or
larger than the raw-safe write size, the planner emits the framed body as one
chunk. Small single-line bodies stay unframed, preserving today's bytes.
4. When the agent does not accept bracketed-paste framing, the planner emits the
body as consecutive chunks no larger than the raw-safe write size, each
continuation chunk carrying the inter-chunk delay.
5. When `SubmitDelay > 0`, the planner appends the submit keystroke as its own
chunk carrying the submit delay. When `SubmitDelay == 0`, it appends the
submit sequence to the final body chunk.
6. The caller writes each chunk in order, waiting for the chunk's delay with the
caller context first.
Ordering is total: the PTY is a single stream, and the caller writes
sequentially, so the body writes and submit keystroke are sent in order. The
unframed pacing reduces burst loss but cannot confirm what the TUI consumed.
## Failure and recovery
A failed write aborts the remaining chunks for that prompt. The session prompt
path returns the failure to its caller, which reverts session state and surfaces
the error, so a half-typed prompt is never reported as delivered. The initial
prompt injector logs the failure and stops rather than retrying, because a retry
would duplicate the already-written prefix in the agent's input.
Delivery does not read the terminal back, so it cannot confirm what the TUI
absorbed. Correctness therefore rests on framing and sizing rather than on
acknowledgement. The 30 ms unframed interval is an empirical pacing policy, not
a universal lossless-delivery guarantee. Any change to these rules needs
evidence from a real TUI.
## Security
Prompt bodies are untrusted for terminal framing. A body that contains a framing
terminator would end the paste early and let the remainder reach the TUI as
terminal input outside the prompt, which in a CLI agent means unintended
commands. The planner therefore neutralizes framing terminators in the body
before wrapping it. It performs no other rewriting: the body is agent-visible
content and must otherwise arrive byte-for-byte.
## Observability
Delivery callers log the byte length of what they write: the initial prompt
injector logs the injected description length, and the session prompt path logs
the prompt length at debug level. The planner itself logs nothing, because it is
pure.
The authoritative delivery check is comparison, not logging: the stored message
text against the receiving agent's own transcript of what it was sent. That
comparison is what exposed the original defect, a loss of exactly N x 1022
bytes from the start of every long body. Per-delivery logging of chunk count and
framing is not implemented; it would let an operator distinguish a mis-planned
delivery from one the TUI mishandled without a transcript.