Merge branch 'main' into evis/ent-1130-code-search-improve-readability-of-terminal · Entire

Merge branch 'main' into evis/ent-1130-code-search-improve-readability-of-terminal

37160e4→main

gtrrz-victor·3d ago·6 files·+211 added/-42 removed

Changes

6

168 unmodified lines

169
170
171
172
172
173
174
175
174
176
177
176
178
179
180
181

168 unmodified lines

}

func (c *CopilotCLIAgent) readHookEnvelope(stdin io.Reader) (*hookEnvelope, error) {
    data, err := io.ReadAll(stdin)
    // Stream one JSON value rather than io.ReadAll so the hook never blocks
    // waiting for stdin EOF that some agents don't send on Windows (issue #1398).
    raw, err := agent.ReadHookInputRaw(stdin)
    if err != nil {
        return nil, fmt.Errorf("failed to read hook input: %w", err)
    }
    return parseHookEnvelope(data)
    return parseHookEnvelope(raw)
}

// resolveTranscriptRef computes the transcript path from the session ID.

Mcmd/entire/cli/agent/copilotcli/lifecycle.go+5/-3

4 unmodified lines

5
6
7
8
9
10
11
12
13
14
145 unmodified lines

160
161
162
160
161
163
164
165
166
167
168
169
170
171
172
173
174
163
175
176
165
166
167
168
177
178
179
171
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228

4 unmodified lines

"errors"
    "fmt"
    "io"
    "os"
    "time"

"golang.org/x/term"

// EventType represents a normalized lifecycle event from any agent.
145 unmodified lines

Metadata map[string]string
}

// ReadAndParseHookInput reads all bytes from stdin and unmarshals JSON into the given type.
// This is a shared helper for agent ParseHookEvent implementations.
// ReadAndParseHookInput decodes a single JSON hook payload from stdin into the
// given type. This is a shared helper for agent ParseHookEvent implementations.
//
// It deliberately does NOT use io.ReadAll, which waits for stdin to reach EOF.
// Agents drive hooks by piping a JSON payload to the hook process, but some
// keep the write end of that pipe open for the hook's lifetime rather than
// closing it after writing — notably on Windows/Git Bash, where a full payload
// arrives but EOF never does. io.ReadAll then blocked indefinitely and the hook
// (e.g. gemini session-start) hung forever (issue #1398). A streaming
// json.Decoder returns as soon as one complete JSON value has been read,
// independent of when — or whether — stdin is closed.
func ReadAndParseHookInput[T any](stdin io.Reader) (*T, error) {
    data, err := io.ReadAll(stdin)
    raw, err := ReadHookInputRaw(stdin)
    if err != nil {
        return nil, fmt.Errorf("failed to read hook input: %w", err)
    }
    if len(data) == 0 {
        return nil, errors.New("empty hook input")
    }
    var result T
    if err := json.Unmarshal(data, &result); err != nil {
        return nil, fmt.Errorf("failed to parse hook input: %w", err)
    }
    return &result, nil
}

// ReadHookInputRaw returns the raw bytes of a single JSON hook payload read from
// stdin, without waiting for EOF. It is the shared primitive behind every
// agent's hook-input read (issue #1398); callers that need custom parsing
// (e.g. key-name fallbacks, or forwarding the bytes to a subprocess) use this
// directly, while the common case uses ReadAndParseHookInput.
func ReadHookInputRaw(stdin io.Reader) (json.RawMessage, error) {
    return ReadHookInputRawLimited(stdin, -1)
}

// ReadHookInputRawLimited is ReadHookInputRaw with a ceiling of limit bytes on
// the JSON value (limit < 0 means unlimited). It is used at the external/plugin
// boundary to bound an untrusted payload — without reintroducing the EOF-wait
// hang, since the streaming decoder still returns on the first complete value.
func ReadHookInputRawLimited(stdin io.Reader, limit int64) (json.RawMessage, error) {
    // If stdin is an interactive terminal there is no payload coming at all: the
    // command was run by hand, or the agent left the console attached instead of
    // wiring up a pipe. Decoding would block waiting for input that never comes,
    // so treat it as empty and return promptly.
    if StdinLooksInteractive(stdin) {
        return nil, errors.New("empty hook input")
    }
    
    r := stdin
    if limit >= 0 {
        r = io.LimitReader(stdin, limit)
    }
    var raw json.RawMessage
    if err := json.NewDecoder(r).Decode(&raw); err != nil {
        if errors.Is(err, io.EOF) {
            return nil, errors.New("empty hook input")
        }
        return nil, fmt.Errorf("failed to parse hook input: %w", err)
    }
    return raw, nil
}

// StdinLooksInteractive reports whether r is an interactive terminal, i.e. no
// piped hook payload is on its way. Hook readers use it to bail out promptly
// instead of blocking on a read that will never complete (issue #1398).
func StdinLooksInteractive(r io.Reader) bool {
    f, ok := r.(*os.File)
    return ok && term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd
}
``

Mcmd/entire/cli/agent/event.go+61/-8

``
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127

package agent

import (
    "io"
    "strings"
    "testing"
    "time"
)

type hookInput struct {
    SessionID      string `json:"session_id"`
    TranscriptPath string `json:"transcript_path"`
}

// TestReadAndParseHookInput_ReturnsBeforeEOF proves the hook reader returns as
// soon as a complete JSON value has arrived, WITHOUT waiting for stdin to be
// closed. On Windows/Git Bash the agent keeps the pipe's write end open for the
// hook's lifetime; io.ReadAll blocked forever there (issue #1398). We simulate
// that by writing the payload to an io.Pipe and never closing the writer.
func TestReadAndParseHookInput_ReturnsBeforeEOF(t *testing.T) {
    t.Parallel()

pr, pw := io.Pipe()
    // Write a complete payload, then hold the pipe open (never Close) — mimics an
    // agent that keeps stdin open after delivering the JSON.
    go func() {
        if _, err := pw.Write([]byte(`{"session_id":"s1","transcript_path":"/t.jsonl"}`)); err != nil {
            _ = pw.CloseWithError(err)
        }
        // Intentionally no pw.Close() on success: stdin stays open, so EOF never arrives.
    }()

type result struct {
        val *hookInput
        err error
    }
    done := make(chan result, 1)
    go func() {
        v, err := ReadAndParseHookInput[hookInput](pr)
        done <- result{v, err}
    }()

select {
    case r := <-done:
        if r.err != nil {
            t.Fatalf("unexpected error: %v", r.err)
        }
        if r.val == nil || r.val.SessionID != "s1" || r.val.TranscriptPath != "/t.jsonl" {
            t.Fatalf("unexpected value: %+v", r.val)
        }
    case <-time.After(3 * time.Second):
        t.Fatal("ReadAndParseHookInput blocked waiting for EOF — regression of #1398")
    }
}

// TestReadHookInputRawLimited_ReturnsBeforeEOF is the external-agent analogue of
// TestReadAndParseHookInput_ReturnsBeforeEOF: the size-bounded raw reader must
// also return on the first complete JSON value without waiting for stdin close
// (issue #1398).
func TestReadHookInputRawLimited_ReturnsBeforeEOF(t *testing.T) {
    t.Parallel()

pr, pw := io.Pipe()
    go func() {
        if _, err := pw.Write([]byte(`{"session_file":"/t.jsonl"}`)); err != nil {
            _ = pw.CloseWithError(err)
        }
        // No Close(): the write end stays open, so EOF never arrives.
    }()

done := make(chan error, 1)
    go func() {
        _, err := ReadHookInputRawLimited(pr, 10*1024*1024)
        done <- err
    }()

select {
    case err := <-done:
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
    case <-time.After(3 * time.Second):
        t.Fatal("ReadHookInputRawLimited blocked waiting for EOF — regression of #1398")
    }
}

// TestReadHookInputRawLimited_RejectsOversized proves the byte ceiling turns an
// over-limit payload into an error rather than an unbounded read.
func TestReadHookInputRawLimited_RejectsOversized(t *testing.T) {
    t.Parallel()

big := `{"k":"` + strings.Repeat("x", 512) + `"}`
    _, err := ReadHookInputRawLimited(strings.NewReader(big), 64)
    if err == nil {
        t.Fatal("expected error for payload exceeding the limit, got nil")
    }
}

func TestReadAndParseHookInput_EmptyInputEOF(t *testing.T) {
    t.Parallel()

_, err := ReadAndParseHookInput[hookInput](strings.NewReader(""))
    if err == nil || !strings.Contains(err.Error(), "empty hook input") {
        t.Fatalf("want 'empty hook input' error, got: %v", err)
    }
}

func TestReadAndParseHookInput_MalformedJSON(t *testing.T) {
    t.Parallel()

_, err := ReadAndParseHookInput[hookInput](strings.NewReader(`{"session_id": INVALID}`))
    if err == nil || !strings.Contains(err.Error(), "failed to parse hook input") {
        t.Fatalf("want 'failed to parse hook input' error, got: %v", err)
    }
}

func TestReadAndParseHookInput_ValidPayload(t *testing.T) {
    t.Parallel()

got, err := ReadAndParseHookInput[hookInput](strings.NewReader(`{"session_id":"abc","transcript_path":"/x"}`))
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got.SessionID != "abc" || got.TranscriptPath != "/x" {
        t.Fatalf("unexpected value: %+v", got)
    }
}
}

``

Acmd/entire/cli/agent/event_test.go+127

``
231 unmodified lines

232
233
234
235
235
236
237
238
239
240
241
242
243
239
244
245
246
247

231 unmodified lines

func (e *Agent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) {
    const maxParseHookBytes = 10 * 1024 * 1024 // 10 MB
    data, err := io.ReadAll(io.LimitReader(stdin, maxParseHookBytes))
    // Stream a single (size-bounded) JSON value rather than io.ReadAll, so the
    // hook never blocks waiting for stdin EOF that some agents don't send on
    // Windows/Git Bash (issue #1398). The external "parse-hook" contract receives
    // the host's hook payload — which is JSON — and we forward its raw bytes
    // verbatim to the subprocess, so a plain byte copy is preserved.
    raw, err := agent.ReadHookInputRawLimited(stdin, maxParseHookBytes)
    if err != nil {
        return nil, fmt.Errorf("parse-hook: read stdin: %w", err)
    }
    stdout, err := e.run(ctx, data, "parse-hook", "--hook", hookName)
    stdout, err := e.run(ctx, raw, "parse-hook", "--hook", hookName)
    if err != nil {
        return nil, fmt.Errorf("parse-hook: %w", err)
    }
}
``

Mcmd/entire/cli/agent/external/external.go+7/-2

``
2 unmodified lines

3
4
5
6
6
7
8
136 unmodified lines

145
146
147
149
148
149
150
151
151
152
153
153
154
155
156
157
158
159
160
154
155
156
157

2 unmodified lines

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "log/slog"
136 unmodified lines

// ParseHookEvent translates a Pi hook invocation into a normalised lifecycle
// event. Implements agent.HookSupport.
func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) {
    data, err := io.ReadAll(stdin)
    // Stream one JSON value rather than io.ReadAll so the hook never blocks
    // waiting for stdin EOF that some agents don't send on Windows (issue #1398).
    parsed, err := agent.ReadAndParseHookInput[piHookPayload](stdin)
    if err != nil {
        return nil, fmt.Errorf("read pi hook input: %w", err)
    }
    if len(data) == 0 {
        return nil, errors.New("empty pi hook input")
    }

var payload piHookPayload
    if err := json.Unmarshal(data, &payload); err != nil {
        return nil, fmt.Errorf("parse pi hook payload: %w", err)
    }
    payload := *parsed

sessionID := payload.SessionID
    if sessionID == "" {
}
``

Mcmd/entire/cli/agent/pi/lifecycle.go+5/-11

``
1 unmodified line

2
3
4
5
6
5
6
7
8
9
10
8 unmodified lines

19
20
21
23
22
23
24
25
26
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
27
28
29
30

1 unmodified line

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"

"github.com/entireio/cli/cmd/entire/cli/agent"
    "github.com/entireio/cli/cmd/entire/cli/strategy"
)

8 unmodified lines

ToolResponse   json.RawMessage `json:"tool_response"`
}

// parseSubagentCheckpointHookInput parses PostToolUse hook input for subagent checkpoints
// parseSubagentCheckpointHookInput parses PostToolUse hook input for subagent
// checkpoints. It streams a single JSON value rather than reading to EOF so the
// claude-code post-todo hook never blocks waiting for a stdin close that some
// agents don't send on Windows (issue #1398).
func parseSubagentCheckpointHookInput(r io.Reader) (*SubagentCheckpointHookInput, error) {
    data, err := io.ReadAll(r)
    if err != nil {
        return nil, fmt.Errorf("failed to read input: %w", err)
    }

if len(data) == 0 {
        return nil, errors.New("empty input")
    }

var input SubagentCheckpointHookInput
    if err := json.Unmarshal(data, &input); err != nil {
        return nil, fmt.Errorf("failed to parse JSON: %w", err)
    }

return &input, nil
    return agent.ReadAndParseHookInput[SubagentCheckpointHookInput](r)
}

// taskToolInput represents the tool_input structure for the Task tool.
``

Mcmd/entire/cli/hooks.go+6/-18