-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdetach.go
More file actions
174 lines (159 loc) · 5.06 KB
/
Copy pathdetach.go
File metadata and controls
174 lines (159 loc) · 5.06 KB
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
package agenthooks
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
const claudeMCPWarmFlag = "--agenthooks-internal-claude-mcp-warm"
const (
codexMCPWarmFlag = "--agenthooks-internal-codex-mcp-warm"
codexLaunchContextFlag = "--agenthooks-internal-codex-launch-context"
maxLaunchContextBytes = 1 << 20
)
// Self-backgrounding for providers that run every hook synchronously (Codex
// parses-but-skips async, quirk #10). The rendered command for a telemetry
// event carries --async: the parent re-execs this binary without the flag as
// a detached child, hands over the stdin payload, and exits so the provider
// unblocks immediately. Threads cannot outlive their process, so surviving
// the parent's exit requires a second process — and the only executable
// guaranteed to exist is this one.
// stripAsyncFlag reports whether args request asynchronous delivery and
// returns the args for the worker child, with every --async removed so the
// child runs the normal synchronous path.
func stripAsyncFlag(args []string) ([]string, bool) {
found := false
rest := make([]string, 0, len(args))
for _, a := range args {
if a == "--async" {
found = true
continue
}
rest = append(rest, a)
}
return rest, found
}
func claudeMCPWarmCWD(args []string) (string, bool) {
prefix := claudeMCPWarmFlag + "="
for _, arg := range args {
if cwd, ok := strings.CutPrefix(arg, prefix); ok {
return cwd, true
}
}
return "", false
}
func hasInternalFlag(args []string, flag string) bool {
for _, arg := range args {
if arg == flag {
return true
}
}
return false
}
func encodeCodexLaunchContext(args []string, stdin io.Reader, launch codexLaunchContext) ([]string, io.Reader, error) {
payload, err := codexLaunchContextPayload(launch)
if err != nil {
return args, stdin, err
}
workerArgs := append([]string(nil), args...)
workerArgs = append(workerArgs, codexLaunchContextFlag)
return workerArgs, io.MultiReader(payload, stdin), nil
}
func encodeCodexMCPWarm(args []string, launch codexLaunchContext) ([]string, io.Reader, error) {
payload, err := codexLaunchContextPayload(launch)
if err != nil {
return args, nil, err
}
workerArgs := append([]string(nil), args...)
workerArgs = append(workerArgs, codexMCPWarmFlag)
return workerArgs, payload, nil
}
func codexLaunchContextPayload(launch codexLaunchContext) (io.Reader, error) {
data, err := json.Marshal(launch)
if err != nil {
return nil, err
}
if len(data) > maxLaunchContextBytes {
return nil, fmt.Errorf("codex launch context exceeds %d bytes", maxLaunchContextBytes)
}
return bytes.NewReader(append(data, '\n')), nil
}
func decodeCodexLaunchContext(stdin io.Reader) (codexLaunchContext, io.Reader, error) {
reader := bufio.NewReader(stdin)
var data []byte
for {
part, more, err := reader.ReadLine()
if err != nil {
return codexLaunchContext{}, reader, err
}
if len(data)+len(part) > maxLaunchContextBytes {
return codexLaunchContext{}, reader, fmt.Errorf("codex launch context exceeds %d bytes", maxLaunchContextBytes)
}
data = append(data, part...)
if !more {
break
}
}
var launch codexLaunchContext
if err := json.Unmarshal(data, &launch); err != nil {
return codexLaunchContext{}, reader, err
}
return launch, reader, nil
}
// detachSelf spawns this binary detached with the given args, streams stdin
// into it, and returns the parent's exit code. The write blocks at most until
// the child starts reading its payload; the provider only waits on the
// parent. Child output goes to the async log used for troubleshooting.
func detachSelf(args []string, stdin io.Reader, stderr io.Writer) int {
if err := startDetachedSelf(args, stdin); err != nil {
_, _ = fmt.Fprintf(stderr, "agenthooks: async: %v\n", err)
}
return 0
}
// startDetachedSelf starts a copy of the current executable that survives this
// process. A nil stdin is used by internal workers that need no hook payload.
func startDetachedSelf(args []string, stdin io.Reader) error {
exe, err := os.Executable()
if err != nil {
return err
}
cmd := exec.Command(exe, args...)
cmd.SysProcAttr = detachSysProcAttr()
logPath := filepath.Join(os.TempDir(), "agenthooks-async.log")
if logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
cmd.Stdout = logFile
cmd.Stderr = logFile
defer logFile.Close()
}
var childStdin io.WriteCloser
if stdin != nil {
childStdin, err = cmd.StdinPipe()
if err != nil {
return err
}
}
if err := cmd.Start(); err != nil {
return err
}
if stdin != nil {
limit := int64(maxPayloadBytes)
if hasInternalFlag(args, codexLaunchContextFlag) || hasInternalFlag(args, codexMCPWarmFlag) {
limit += maxLaunchContextBytes + 1
}
if _, err := io.Copy(childStdin, io.LimitReader(stdin, limit)); err != nil {
_ = childStdin.Close()
_ = cmd.Process.Release()
return fmt.Errorf("forwarding payload: %w", err)
}
_ = childStdin.Close()
}
// Deliberately no Wait: the child is the detached worker. Release lets
// the parent exit without reaping.
_ = cmd.Process.Release()
return nil
}