@pi-in-go/pigpen-acp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +23 -0
- package/LICENSE +22 -0
- package/README.md +110 -0
- package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
- package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
- package/extensions/acp/cmd/pig-acp/go.mod +6 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/main.go +177 -0
- package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
- package/extensions/acp/extension.go +63 -0
- package/extensions/acp/extension_test.go +111 -0
- package/extensions/acp/fakehost_test.go +548 -0
- package/extensions/acp/go.mod +6 -0
- package/extensions/acp/go.sum +2 -0
- package/package.json +41 -0
- package/port/PORT.md +147 -0
- package/port/interop/package-lock.json +89 -0
- package/port/interop/package.json +13 -0
- package/port/interop/run.mjs +229 -0
- package/port/mutate.mjs +110 -0
- package/port/mutation-results.txt +107 -0
- package/port/mutations.json +748 -0
- package/port/oracle/pi-acp/LICENSE +21 -0
- package/port/oracle/pi-acp/README.md +209 -0
- package/port/oracle/pi-acp/package.json +65 -0
- package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
- package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
- package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
- package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
- package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
- package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
- package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
- package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
- package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
- package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
- package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
- package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
- package/port/oracle/pi-acp/src/index.ts +82 -0
- package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
- package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
- package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
- package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
- package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
- package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
- package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
- package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
- package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
- package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
- package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
- package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
- package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
- package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
- package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
- package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
- package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
- package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
- package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
- package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
- package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
- package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
- package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
- package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
- package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
- package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
- package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
- package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
- package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
- package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
- package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
- package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
- package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
- package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
- package/port/schema/LICENSE +191 -0
- package/port/schema/schema.json +8015 -0
- package/port/upstream-tests.json +201 -0
- package/provenance.json +28 -0
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
package acp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"math"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strconv"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync"
|
|
14
|
+
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/acp/cmd/pig-acp/internal/pirpc"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// This file ports src/acp/session.ts of pi-acp: one ACP session bound to one pig RPC child, the
|
|
19
|
+
// translation of pig events into `session/update` notifications, the turn queue, and the mapping
|
|
20
|
+
// of extension dialogs onto `session/request_permission`.
|
|
21
|
+
|
|
22
|
+
// SessionOptions create a Session (PiAcpSession's constructor arguments).
|
|
23
|
+
type SessionOptions struct {
|
|
24
|
+
SessionID string
|
|
25
|
+
Cwd string
|
|
26
|
+
McpServers []any
|
|
27
|
+
Proc Proc
|
|
28
|
+
Conn Conn
|
|
29
|
+
FileCommands []FileSlashCommand
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const (
|
|
33
|
+
choiceOptionPrefix = "choice-"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
var extensionUIRawInputKeys = []string{"title", "message", "options", "placeholder", "prefill"}
|
|
37
|
+
|
|
38
|
+
// serial runs functions one at a time, in the order they were submitted, without ever blocking
|
|
39
|
+
// the submitter. It is the Go form of a promise chain (lastEmit in the original).
|
|
40
|
+
type serial struct {
|
|
41
|
+
mu sync.Mutex
|
|
42
|
+
cond *sync.Cond
|
|
43
|
+
q []func()
|
|
44
|
+
busy bool
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func newSerial() *serial {
|
|
48
|
+
s := &serial{}
|
|
49
|
+
s.cond = sync.NewCond(&s.mu)
|
|
50
|
+
return s
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func (s *serial) do(fn func()) {
|
|
54
|
+
s.mu.Lock()
|
|
55
|
+
s.q = append(s.q, fn)
|
|
56
|
+
if !s.busy {
|
|
57
|
+
s.busy = true
|
|
58
|
+
go s.run()
|
|
59
|
+
}
|
|
60
|
+
s.mu.Unlock()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func (s *serial) run() {
|
|
64
|
+
for {
|
|
65
|
+
s.mu.Lock()
|
|
66
|
+
if len(s.q) == 0 {
|
|
67
|
+
s.busy = false
|
|
68
|
+
s.cond.Broadcast()
|
|
69
|
+
s.mu.Unlock()
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
fn := s.q[0]
|
|
73
|
+
s.q = s.q[1:]
|
|
74
|
+
s.mu.Unlock()
|
|
75
|
+
fn()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// wait blocks until everything submitted so far has run.
|
|
80
|
+
func (s *serial) wait() {
|
|
81
|
+
s.mu.Lock()
|
|
82
|
+
for s.busy || len(s.q) > 0 {
|
|
83
|
+
s.cond.Wait()
|
|
84
|
+
}
|
|
85
|
+
s.mu.Unlock()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type queuedTurn struct {
|
|
89
|
+
message string
|
|
90
|
+
images []Image
|
|
91
|
+
result chan TurnResult
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type fileSnapshot struct {
|
|
95
|
+
path string
|
|
96
|
+
oldText *string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Session is one ACP session bound to one pig child.
|
|
100
|
+
type Session struct {
|
|
101
|
+
id string
|
|
102
|
+
cwd string
|
|
103
|
+
mcpServers []any
|
|
104
|
+
proc Proc
|
|
105
|
+
conn Conn
|
|
106
|
+
cmds []FileSlashCommand
|
|
107
|
+
|
|
108
|
+
emitQ *serial // session/update notifications, in order
|
|
109
|
+
ackQ *serial // acknowledgements of fire-and-forget extension dialogs, in order
|
|
110
|
+
|
|
111
|
+
mu sync.Mutex
|
|
112
|
+
startupInfo *string
|
|
113
|
+
startupSent bool
|
|
114
|
+
cancelRequested bool
|
|
115
|
+
pending *queuedTurn
|
|
116
|
+
queue []*queuedTurn
|
|
117
|
+
currentToolCalls map[string]string // pending | in_progress, never downgraded
|
|
118
|
+
inAgentLoop bool
|
|
119
|
+
fileSnapshots map[string]fileSnapshot
|
|
120
|
+
fileMutation map[string]bool
|
|
121
|
+
bashToolCalls map[string]bool
|
|
122
|
+
bashSnapshots map[string]string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// NewSession subscribes to the child's events and returns the session.
|
|
126
|
+
func NewSession(opts SessionOptions) *Session {
|
|
127
|
+
s := &Session{
|
|
128
|
+
id: opts.SessionID, cwd: opts.Cwd, mcpServers: opts.McpServers, proc: opts.Proc, conn: opts.Conn, cmds: opts.FileCommands,
|
|
129
|
+
emitQ: newSerial(), ackQ: newSerial(),
|
|
130
|
+
currentToolCalls: map[string]string{}, fileSnapshots: map[string]fileSnapshot{}, fileMutation: map[string]bool{},
|
|
131
|
+
bashToolCalls: map[string]bool{}, bashSnapshots: map[string]string{},
|
|
132
|
+
}
|
|
133
|
+
s.proc.OnEvent(s.handlePiEvent)
|
|
134
|
+
return s
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ID is the ACP session id.
|
|
138
|
+
func (s *Session) ID() string { return s.id }
|
|
139
|
+
|
|
140
|
+
// Cwd is the session working directory.
|
|
141
|
+
func (s *Session) Cwd() string { return s.cwd }
|
|
142
|
+
|
|
143
|
+
// Proc is the pig child.
|
|
144
|
+
func (s *Session) Proc() Proc { return s.proc }
|
|
145
|
+
|
|
146
|
+
// SetStartupInfo records the startup text to send once.
|
|
147
|
+
func (s *Session) SetStartupInfo(text string) {
|
|
148
|
+
s.mu.Lock()
|
|
149
|
+
s.startupInfo = &text
|
|
150
|
+
s.startupSent = false
|
|
151
|
+
s.mu.Unlock()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// SendStartupInfoIfPending sends the startup text if it was not sent yet. Some clients render
|
|
155
|
+
// agent messages only once their UI is ready, so the agent calls this shortly after session/new.
|
|
156
|
+
func (s *Session) SendStartupInfoIfPending() {
|
|
157
|
+
s.mu.Lock()
|
|
158
|
+
if s.startupSent || s.startupInfo == nil || *s.startupInfo == "" {
|
|
159
|
+
s.mu.Unlock()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
s.startupSent = true
|
|
163
|
+
text := *s.startupInfo
|
|
164
|
+
s.mu.Unlock()
|
|
165
|
+
s.emit(Update{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": text}})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func textChunk(kind, text string) Update {
|
|
169
|
+
return Update{"sessionUpdate": kind, "content": map[string]any{"type": "text", "text": text}}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func queueInfo(depth int, running bool) Update {
|
|
173
|
+
return Update{"sessionUpdate": "session_info_update", "_meta": map[string]any{"piAcp": map[string]any{"queueDepth": depth, "running": running}}}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Prompt starts a turn now, or queues it behind the running one. The channel yields once.
|
|
177
|
+
func (s *Session) Prompt(message string, images []Image) <-chan TurnResult {
|
|
178
|
+
// The startup text goes out with the first prompt when nothing sent it earlier.
|
|
179
|
+
s.SendStartupInfoIfPending()
|
|
180
|
+
// pig's RPC mode does not expand prompt templates, so it is done here.
|
|
181
|
+
t := &queuedTurn{message: ExpandSlashCommand(message, s.cmds), images: images, result: make(chan TurnResult, 1)}
|
|
182
|
+
|
|
183
|
+
s.mu.Lock()
|
|
184
|
+
if s.pending != nil {
|
|
185
|
+
s.queue = append(s.queue, t)
|
|
186
|
+
depth := len(s.queue)
|
|
187
|
+
s.mu.Unlock()
|
|
188
|
+
s.emit(textChunk("agent_message_chunk", fmt.Sprintf("Queued message (position %d).", depth)))
|
|
189
|
+
s.emit(queueInfo(depth, true))
|
|
190
|
+
return t.result
|
|
191
|
+
}
|
|
192
|
+
s.mu.Unlock()
|
|
193
|
+
s.startTurn(t)
|
|
194
|
+
return t.result
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Cancel aborts the running turn and clears the queue.
|
|
198
|
+
func (s *Session) Cancel() error {
|
|
199
|
+
s.mu.Lock()
|
|
200
|
+
s.cancelRequested = true
|
|
201
|
+
queued := s.queue
|
|
202
|
+
s.queue = nil
|
|
203
|
+
running := s.pending != nil
|
|
204
|
+
s.mu.Unlock()
|
|
205
|
+
if len(queued) > 0 {
|
|
206
|
+
for _, t := range queued {
|
|
207
|
+
t.result <- TurnResult{Reason: StopCancelled}
|
|
208
|
+
}
|
|
209
|
+
s.emit(textChunk("agent_message_chunk", "Cleared queued prompts."))
|
|
210
|
+
s.emit(queueInfo(0, running))
|
|
211
|
+
}
|
|
212
|
+
// If nothing is running, aborting is a no-op.
|
|
213
|
+
return s.proc.Abort()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// WasCancelRequested reports whether the running turn was cancelled.
|
|
217
|
+
func (s *Session) WasCancelRequested() bool {
|
|
218
|
+
s.mu.Lock()
|
|
219
|
+
defer s.mu.Unlock()
|
|
220
|
+
return s.cancelRequested
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func (s *Session) emit(update Update) {
|
|
224
|
+
id, conn := s.id, s.conn
|
|
225
|
+
s.emitQ.do(func() {
|
|
226
|
+
// A failed notification (the client went away) must not stop the turn.
|
|
227
|
+
_ = conn.SessionUpdate(id, update)
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
func (s *Session) flushEmits() { s.emitQ.wait() }
|
|
232
|
+
|
|
233
|
+
// toUsageUpdate maps stats.contextUsage to a usage_update, or nil when pig reports no
|
|
234
|
+
// trustworthy token count (tokens null right after compaction) or unusable numbers.
|
|
235
|
+
func toUsageUpdate(stats SessionStats) Update {
|
|
236
|
+
cu := asObject(stats["contextUsage"])
|
|
237
|
+
used, ok1 := safeInteger(cu["tokens"])
|
|
238
|
+
size, ok2 := safeInteger(cu["contextWindow"])
|
|
239
|
+
if !ok1 || used < 0 || !ok2 || size <= 0 {
|
|
240
|
+
return nil
|
|
241
|
+
}
|
|
242
|
+
return Update{"sessionUpdate": "usage_update", "used": used, "size": size}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func safeInteger(v any) (int64, bool) {
|
|
246
|
+
f, ok := asNumber(v)
|
|
247
|
+
if !ok || f != math.Trunc(f) || math.Abs(f) > 9007199254740991 {
|
|
248
|
+
return 0, false
|
|
249
|
+
}
|
|
250
|
+
return int64(f), true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// PublishContextUsage sends a usage_update from get_session_stats and flushes updates. It never
|
|
254
|
+
// fails or delays the turn beyond the stats timeout: context usage is auxiliary.
|
|
255
|
+
func (s *Session) PublishContextUsage() {
|
|
256
|
+
if stats, err := s.proc.GetSessionStats(SessionStatsTimeoutMs); err == nil {
|
|
257
|
+
if u := toUsageUpdate(stats); u != nil {
|
|
258
|
+
s.emit(u)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
s.flushEmits()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func (s *Session) settleTurn() {
|
|
265
|
+
// Every update derived from pig events, plus the final usage update, is delivered before the
|
|
266
|
+
// ACP session/prompt request resolves.
|
|
267
|
+
s.PublishContextUsage()
|
|
268
|
+
|
|
269
|
+
s.mu.Lock()
|
|
270
|
+
reason := StopEndTurn
|
|
271
|
+
if s.cancelRequested {
|
|
272
|
+
reason = StopCancelled
|
|
273
|
+
}
|
|
274
|
+
pending := s.pending
|
|
275
|
+
s.pending = nil
|
|
276
|
+
s.inAgentLoop = false
|
|
277
|
+
var next *queuedTurn
|
|
278
|
+
if len(s.queue) > 0 {
|
|
279
|
+
next, s.queue = s.queue[0], s.queue[1:]
|
|
280
|
+
}
|
|
281
|
+
remaining := len(s.queue)
|
|
282
|
+
s.mu.Unlock()
|
|
283
|
+
if next == nil {
|
|
284
|
+
// The idle notice goes out before the response, so a client that stops listening at the
|
|
285
|
+
// response has seen the whole turn.
|
|
286
|
+
s.emit(queueInfo(0, false))
|
|
287
|
+
s.flushEmits()
|
|
288
|
+
}
|
|
289
|
+
if pending != nil {
|
|
290
|
+
pending.result <- TurnResult{Reason: reason}
|
|
291
|
+
}
|
|
292
|
+
if next != nil {
|
|
293
|
+
s.emit(textChunk("agent_message_chunk", fmt.Sprintf("Starting queued message. (%d remaining)", remaining)))
|
|
294
|
+
s.startTurn(next)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func (s *Session) startTurn(t *queuedTurn) {
|
|
299
|
+
s.mu.Lock()
|
|
300
|
+
s.cancelRequested = false
|
|
301
|
+
s.inAgentLoop = false
|
|
302
|
+
s.pending = t
|
|
303
|
+
depth := len(s.queue)
|
|
304
|
+
s.mu.Unlock()
|
|
305
|
+
s.emit(queueInfo(depth, true))
|
|
306
|
+
|
|
307
|
+
// Completion is determined by pig's events, not by the RPC response: the prompt command only
|
|
308
|
+
// acknowledges acceptance, and retry, compaction or queued continuations can emit several
|
|
309
|
+
// agent_end events before agent_settled.
|
|
310
|
+
go func() {
|
|
311
|
+
err := s.proc.Prompt(t.message, t.images)
|
|
312
|
+
if err == nil {
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
// The child failed before agent_settled: flush what is queued, then end the turn.
|
|
316
|
+
s.flushEmits()
|
|
317
|
+
s.mu.Lock()
|
|
318
|
+
pending := s.pending
|
|
319
|
+
cancelled := s.cancelRequested
|
|
320
|
+
s.pending = nil
|
|
321
|
+
s.inAgentLoop = false
|
|
322
|
+
depth := len(s.queue)
|
|
323
|
+
s.mu.Unlock()
|
|
324
|
+
if pending != nil {
|
|
325
|
+
if authErr := MaybeAuthRequiredError(err); authErr != nil {
|
|
326
|
+
pending.result <- TurnResult{Err: authErr}
|
|
327
|
+
} else if cancelled {
|
|
328
|
+
pending.result <- TurnResult{Reason: StopCancelled}
|
|
329
|
+
} else {
|
|
330
|
+
pending.result <- TurnResult{Reason: StopError}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// The queue is not restarted: pig may be unhealthy. Only the depth metadata is cleared.
|
|
334
|
+
s.emit(queueInfo(depth, false))
|
|
335
|
+
}()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- tool helpers (module-level functions of the original) ----
|
|
339
|
+
|
|
340
|
+
func findUniqueLineNumber(text, needle string) (int, bool) {
|
|
341
|
+
if needle == "" {
|
|
342
|
+
return 0, false
|
|
343
|
+
}
|
|
344
|
+
first := strings.Index(text, needle)
|
|
345
|
+
if first < 0 {
|
|
346
|
+
return 0, false
|
|
347
|
+
}
|
|
348
|
+
if strings.Contains(text[first+len(needle):], needle) {
|
|
349
|
+
return 0, false
|
|
350
|
+
}
|
|
351
|
+
return strings.Count(text[:first], "\n") + 1, true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
func getToolPath(args any) (string, bool) {
|
|
355
|
+
rec := asObject(args)
|
|
356
|
+
if p, ok := rec["path"].(string); ok {
|
|
357
|
+
return p, true
|
|
358
|
+
}
|
|
359
|
+
if p, ok := rec["file_path"].(string); ok {
|
|
360
|
+
return p, true
|
|
361
|
+
}
|
|
362
|
+
return "", false
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
type parsedEdit struct{ oldText, newText string }
|
|
366
|
+
|
|
367
|
+
func editsOf(args any) []any {
|
|
368
|
+
rec := asObject(args)
|
|
369
|
+
edits := rec["edits"]
|
|
370
|
+
if s, ok := edits.(string); ok {
|
|
371
|
+
var parsed any
|
|
372
|
+
if json.Unmarshal([]byte(s), &parsed) != nil {
|
|
373
|
+
return nil
|
|
374
|
+
}
|
|
375
|
+
edits = parsed
|
|
376
|
+
}
|
|
377
|
+
arr, _ := edits.([]any)
|
|
378
|
+
return arr
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
func getEditOldTexts(args any) []string {
|
|
382
|
+
rec := asObject(args)
|
|
383
|
+
var olds []string
|
|
384
|
+
add := func(s string) {
|
|
385
|
+
for _, o := range olds {
|
|
386
|
+
if o == s {
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
olds = append(olds, s)
|
|
391
|
+
}
|
|
392
|
+
// Edits that carry both oldText and newText come first, then any bare oldText.
|
|
393
|
+
if o, ok := rec["oldText"].(string); ok {
|
|
394
|
+
if _, ok := rec["newText"].(string); ok {
|
|
395
|
+
olds = append(olds, o)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for _, e := range editsOf(args) {
|
|
399
|
+
m := asObject(e)
|
|
400
|
+
if o, ok := m["oldText"].(string); ok {
|
|
401
|
+
if _, ok := m["newText"].(string); ok {
|
|
402
|
+
olds = append(olds, o)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if o, ok := rec["oldText"].(string); ok {
|
|
407
|
+
add(o)
|
|
408
|
+
}
|
|
409
|
+
for _, e := range editsOf(args) {
|
|
410
|
+
if o, ok := asObject(e)["oldText"].(string); ok {
|
|
411
|
+
add(o)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return olds
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func resolvePath(cwd, p string) string {
|
|
418
|
+
if filepath.IsAbs(p) {
|
|
419
|
+
return filepath.Clean(p)
|
|
420
|
+
}
|
|
421
|
+
return filepath.Join(cwd, p)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
func toToolCallLocations(args any, cwd string, line int) []map[string]any {
|
|
425
|
+
path, ok := getToolPath(args)
|
|
426
|
+
if !ok || path == "" {
|
|
427
|
+
return nil
|
|
428
|
+
}
|
|
429
|
+
loc := map[string]any{"path": resolvePath(cwd, path)}
|
|
430
|
+
if line > 0 {
|
|
431
|
+
loc["line"] = line
|
|
432
|
+
}
|
|
433
|
+
return []map[string]any{loc}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
func toToolKind(name string) string {
|
|
437
|
+
switch name {
|
|
438
|
+
case "read":
|
|
439
|
+
return "read"
|
|
440
|
+
case "write", "edit":
|
|
441
|
+
return "edit"
|
|
442
|
+
case "bash":
|
|
443
|
+
return "execute"
|
|
444
|
+
}
|
|
445
|
+
return "other"
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// set adds key to m unless v is nil (a JS `undefined`, dropped by JSON.stringify).
|
|
449
|
+
func set(m Update, key string, v any) {
|
|
450
|
+
switch x := v.(type) {
|
|
451
|
+
case nil:
|
|
452
|
+
return
|
|
453
|
+
case []map[string]any:
|
|
454
|
+
if x == nil {
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
case []any:
|
|
458
|
+
if x == nil {
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
m[key] = v
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
func (s *Session) emitBashToolCall(kind, id, toolName string, args any, status string, locations []map[string]any, includeTerminal bool) {
|
|
466
|
+
s.mu.Lock()
|
|
467
|
+
s.bashToolCalls[id] = true
|
|
468
|
+
s.mu.Unlock()
|
|
469
|
+
title := toolName
|
|
470
|
+
if c, ok := BashCommand(args); ok {
|
|
471
|
+
title = c
|
|
472
|
+
}
|
|
473
|
+
u := Update{"sessionUpdate": kind, "toolCallId": id, "title": title, "kind": "execute", "status": status}
|
|
474
|
+
set(u, "locations", locations)
|
|
475
|
+
if includeTerminal {
|
|
476
|
+
u["content"] = BashTerminalContent(id)
|
|
477
|
+
u["_meta"] = BashTerminalInfoMeta(id, s.cwd)
|
|
478
|
+
}
|
|
479
|
+
s.emit(u)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
func (s *Session) emitBashOutputUpdate(id, status string, result any, isError bool) {
|
|
483
|
+
text := BashResultText(result)
|
|
484
|
+
s.mu.Lock()
|
|
485
|
+
previous := s.bashSnapshots[id]
|
|
486
|
+
s.bashSnapshots[id] = text
|
|
487
|
+
s.mu.Unlock()
|
|
488
|
+
meta := map[string]any{}
|
|
489
|
+
if delta := BashOutputDelta(previous, text); delta != "" {
|
|
490
|
+
for k, v := range BashTerminalOutputMeta(id, delta) {
|
|
491
|
+
meta[k] = v
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if status == "completed" || status == "failed" {
|
|
495
|
+
for k, v := range BashTerminalExitMeta(id, BashExitCode(result, isError)) {
|
|
496
|
+
meta[k] = v
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
s.emit(Update{"sessionUpdate": "tool_call_update", "toolCallId": id, "status": status, "_meta": meta})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
func (s *Session) cleanupToolCall(id string) {
|
|
503
|
+
s.mu.Lock()
|
|
504
|
+
delete(s.currentToolCalls, id)
|
|
505
|
+
delete(s.fileSnapshots, id)
|
|
506
|
+
delete(s.fileMutation, id)
|
|
507
|
+
delete(s.bashToolCalls, id)
|
|
508
|
+
delete(s.bashSnapshots, id)
|
|
509
|
+
s.mu.Unlock()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
func jsStr(v any, fallback string) string {
|
|
513
|
+
if v == nil {
|
|
514
|
+
return fallback
|
|
515
|
+
}
|
|
516
|
+
return jsString(v)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
func newUUID() string {
|
|
520
|
+
var b [16]byte
|
|
521
|
+
_, _ = rand.Read(b[:])
|
|
522
|
+
b[6] = b[6]&0x0f | 0x40
|
|
523
|
+
b[8] = b[8]&0x3f | 0x80
|
|
524
|
+
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
func (s *Session) handlePiEvent(ev Event) {
|
|
528
|
+
typ := jsStr(ev["type"], "")
|
|
529
|
+
switch typ {
|
|
530
|
+
case "message_update":
|
|
531
|
+
s.handleMessageUpdate(ev)
|
|
532
|
+
case "tool_execution_start":
|
|
533
|
+
s.handleToolStart(ev)
|
|
534
|
+
case "tool_execution_update":
|
|
535
|
+
s.handleToolUpdate(ev)
|
|
536
|
+
case "tool_execution_end":
|
|
537
|
+
s.handleToolEnd(ev)
|
|
538
|
+
case "extension_ui_request":
|
|
539
|
+
s.handleExtensionUIRequest(ev)
|
|
540
|
+
case "auto_retry_start":
|
|
541
|
+
s.emit(textChunk("agent_message_chunk", formatAutoRetryMessage(ev)))
|
|
542
|
+
case "auto_retry_end":
|
|
543
|
+
s.emit(textChunk("agent_message_chunk", "Retry finished, resuming."))
|
|
544
|
+
case "auto_compaction_start":
|
|
545
|
+
s.emit(textChunk("agent_message_chunk", "Context nearing limit, running automatic compaction..."))
|
|
546
|
+
case "auto_compaction_end":
|
|
547
|
+
s.emit(textChunk("agent_message_chunk", "Automatic compaction finished; context was summarized to continue the session."))
|
|
548
|
+
case "agent_start":
|
|
549
|
+
s.mu.Lock()
|
|
550
|
+
s.inAgentLoop = true
|
|
551
|
+
s.mu.Unlock()
|
|
552
|
+
case "turn_end":
|
|
553
|
+
// A turn_end is a sub-step (for example tool_use); the ACP prompt waits for agent_settled.
|
|
554
|
+
case "agent_end":
|
|
555
|
+
// One low-level run ended; pig may still retry, compact or continue, so the turn stays open.
|
|
556
|
+
s.mu.Lock()
|
|
557
|
+
s.inAgentLoop = false
|
|
558
|
+
s.mu.Unlock()
|
|
559
|
+
case "agent_settled":
|
|
560
|
+
go s.settleTurn()
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
func (s *Session) handleMessageUpdate(ev Event) {
|
|
565
|
+
ame := asObject(ev["assistantMessageEvent"])
|
|
566
|
+
typ, _ := ame["type"].(string)
|
|
567
|
+
switch typ {
|
|
568
|
+
case "text_delta":
|
|
569
|
+
if d, ok := ame["delta"].(string); ok {
|
|
570
|
+
s.emit(textChunk("agent_message_chunk", d))
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
case "thinking_delta":
|
|
574
|
+
if d, ok := ame["delta"].(string); ok {
|
|
575
|
+
s.emit(textChunk("agent_thought_chunk", d))
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if typ != "toolcall_start" && typ != "toolcall_delta" && typ != "toolcall_end" {
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
// pig sometimes puts the tool call on the event, and always in the partial message at contentIndex.
|
|
583
|
+
toolCall := ame["toolCall"]
|
|
584
|
+
if toolCall == nil {
|
|
585
|
+
idx := 0
|
|
586
|
+
if f, ok := asNumber(ame["contentIndex"]); ok {
|
|
587
|
+
idx = int(f)
|
|
588
|
+
}
|
|
589
|
+
content, _ := asObject(ame["partial"])["content"].([]any)
|
|
590
|
+
if idx >= 0 && idx < len(content) {
|
|
591
|
+
toolCall = content[idx]
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
tc := asObject(toolCall)
|
|
595
|
+
id := jsStr(tc["id"], "")
|
|
596
|
+
name := jsStr(tc["name"], "tool")
|
|
597
|
+
if id == "" {
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
var rawInput any
|
|
601
|
+
if args, ok := tc["arguments"]; ok && truthy(args) && isJSObject(args) {
|
|
602
|
+
rawInput = args
|
|
603
|
+
} else if ps := jsStr(tc["partialArgs"], ""); ps != "" {
|
|
604
|
+
var parsed any
|
|
605
|
+
if json.Unmarshal([]byte(ps), &parsed) == nil {
|
|
606
|
+
rawInput = parsed
|
|
607
|
+
} else {
|
|
608
|
+
rawInput = map[string]any{"partialArgs": ps}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
locations := toToolCallLocations(rawInput, s.cwd, 0)
|
|
612
|
+
s.mu.Lock()
|
|
613
|
+
existing, has := s.currentToolCalls[id]
|
|
614
|
+
if !has {
|
|
615
|
+
s.currentToolCalls[id] = "pending"
|
|
616
|
+
}
|
|
617
|
+
s.mu.Unlock()
|
|
618
|
+
// Never downgrade a status that already advanced (for example via tool_execution_start).
|
|
619
|
+
status := "pending"
|
|
620
|
+
if has {
|
|
621
|
+
status = existing
|
|
622
|
+
}
|
|
623
|
+
switch {
|
|
624
|
+
case IsBashTool(name):
|
|
625
|
+
kind := "tool_call"
|
|
626
|
+
if has {
|
|
627
|
+
kind = "tool_call_update"
|
|
628
|
+
}
|
|
629
|
+
s.emitBashToolCall(kind, id, name, rawInput, status, locations, !has)
|
|
630
|
+
case !has:
|
|
631
|
+
u := Update{"sessionUpdate": "tool_call", "toolCallId": id, "title": name, "kind": toToolKind(name), "status": status}
|
|
632
|
+
set(u, "locations", locations)
|
|
633
|
+
set(u, "rawInput", rawInput)
|
|
634
|
+
s.emit(u)
|
|
635
|
+
default:
|
|
636
|
+
// Keep rawInput current while the arguments stream; the status stays as it is.
|
|
637
|
+
u := Update{"sessionUpdate": "tool_call_update", "toolCallId": id, "status": status}
|
|
638
|
+
set(u, "locations", locations)
|
|
639
|
+
set(u, "rawInput", rawInput)
|
|
640
|
+
s.emit(u)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
func isJSObject(v any) bool {
|
|
645
|
+
switch v.(type) {
|
|
646
|
+
case map[string]any, []any:
|
|
647
|
+
return true
|
|
648
|
+
}
|
|
649
|
+
return false
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
func (s *Session) handleToolStart(ev Event) {
|
|
653
|
+
id := newUUID()
|
|
654
|
+
if v, ok := ev["toolCallId"]; ok && v != nil {
|
|
655
|
+
id = jsString(v)
|
|
656
|
+
}
|
|
657
|
+
name := jsStr(ev["toolName"], "tool")
|
|
658
|
+
args, hasArgs := ev["args"]
|
|
659
|
+
|
|
660
|
+
if IsBashTool(name) {
|
|
661
|
+
locations := toToolCallLocations(args, s.cwd, 0)
|
|
662
|
+
s.mu.Lock()
|
|
663
|
+
_, has := s.currentToolCalls[id]
|
|
664
|
+
s.currentToolCalls[id] = "in_progress"
|
|
665
|
+
s.mu.Unlock()
|
|
666
|
+
kind := "tool_call"
|
|
667
|
+
if has {
|
|
668
|
+
kind = "tool_call_update"
|
|
669
|
+
}
|
|
670
|
+
s.emitBashToolCall(kind, id, name, args, "in_progress", locations, !has)
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Capture the file before it is mutated so a structured diff can be sent afterwards.
|
|
675
|
+
line := 0
|
|
676
|
+
if name == "edit" || name == "write" {
|
|
677
|
+
s.mu.Lock()
|
|
678
|
+
s.fileMutation[id] = true
|
|
679
|
+
s.mu.Unlock()
|
|
680
|
+
if p, ok := getToolPath(args); ok && p != "" {
|
|
681
|
+
data, err := os.ReadFile(resolvePath(s.cwd, p))
|
|
682
|
+
s.mu.Lock()
|
|
683
|
+
if err != nil {
|
|
684
|
+
s.fileSnapshots[id] = fileSnapshot{path: p}
|
|
685
|
+
} else {
|
|
686
|
+
old := string(data)
|
|
687
|
+
s.fileSnapshots[id] = fileSnapshot{path: p, oldText: &old}
|
|
688
|
+
}
|
|
689
|
+
s.mu.Unlock()
|
|
690
|
+
if err == nil && name == "edit" {
|
|
691
|
+
for _, needle := range getEditOldTexts(args) {
|
|
692
|
+
if n, ok := findUniqueLineNumber(string(data), needle); ok {
|
|
693
|
+
line = n
|
|
694
|
+
break
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
locations := toToolCallLocations(args, s.cwd, line)
|
|
701
|
+
s.mu.Lock()
|
|
702
|
+
_, has := s.currentToolCalls[id]
|
|
703
|
+
s.currentToolCalls[id] = "in_progress"
|
|
704
|
+
s.mu.Unlock()
|
|
705
|
+
kind := "tool_call"
|
|
706
|
+
if has {
|
|
707
|
+
kind = "tool_call_update"
|
|
708
|
+
}
|
|
709
|
+
u := Update{"sessionUpdate": kind, "toolCallId": id, "status": "in_progress"}
|
|
710
|
+
if !has {
|
|
711
|
+
u["title"], u["kind"] = name, toToolKind(name)
|
|
712
|
+
}
|
|
713
|
+
set(u, "locations", locations)
|
|
714
|
+
if hasArgs {
|
|
715
|
+
set(u, "rawInput", args)
|
|
716
|
+
}
|
|
717
|
+
s.emit(u)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
func textContent(text string) []any {
|
|
721
|
+
return []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": text}}}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
func (s *Session) handleToolUpdate(ev Event) {
|
|
725
|
+
id := jsStr(ev["toolCallId"], "")
|
|
726
|
+
if id == "" {
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
partial, hasPartial := ev["partialResult"]
|
|
730
|
+
s.mu.Lock()
|
|
731
|
+
isBash := s.bashToolCalls[id]
|
|
732
|
+
isFile := s.fileMutation[id]
|
|
733
|
+
s.mu.Unlock()
|
|
734
|
+
if isBash {
|
|
735
|
+
s.emitBashOutputUpdate(id, "in_progress", partial, false)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
text := ""
|
|
739
|
+
if !isFile {
|
|
740
|
+
text = ToolResultToText(partial)
|
|
741
|
+
}
|
|
742
|
+
u := Update{"sessionUpdate": "tool_call_update", "toolCallId": id, "status": "in_progress"}
|
|
743
|
+
if text != "" {
|
|
744
|
+
u["content"] = textContent(text)
|
|
745
|
+
}
|
|
746
|
+
if !isFile && hasPartial {
|
|
747
|
+
u["rawOutput"] = partial
|
|
748
|
+
}
|
|
749
|
+
s.emit(u)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
func (s *Session) handleToolEnd(ev Event) {
|
|
753
|
+
id := jsStr(ev["toolCallId"], "")
|
|
754
|
+
if id == "" {
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
result, hasResult := ev["result"]
|
|
758
|
+
isError := truthy(ev["isError"])
|
|
759
|
+
s.mu.Lock()
|
|
760
|
+
isBash := s.bashToolCalls[id]
|
|
761
|
+
snap, hasSnap := s.fileSnapshots[id]
|
|
762
|
+
s.mu.Unlock()
|
|
763
|
+
status := "completed"
|
|
764
|
+
if isError {
|
|
765
|
+
status = "failed"
|
|
766
|
+
}
|
|
767
|
+
if isBash {
|
|
768
|
+
s.emitBashOutputUpdate(id, status, result, isError)
|
|
769
|
+
s.cleanupToolCall(id)
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
text := ToolResultToText(result)
|
|
773
|
+
var content []any
|
|
774
|
+
hasDiff := false
|
|
775
|
+
if !isError && hasSnap {
|
|
776
|
+
if data, err := os.ReadFile(resolvePath(s.cwd, snap.path)); err == nil {
|
|
777
|
+
newText := string(data)
|
|
778
|
+
if snap.oldText == nil || newText != *snap.oldText {
|
|
779
|
+
hasDiff = true
|
|
780
|
+
var old any
|
|
781
|
+
if snap.oldText != nil {
|
|
782
|
+
old = *snap.oldText
|
|
783
|
+
}
|
|
784
|
+
content = []any{map[string]any{"type": "diff", "path": snap.path, "oldText": old, "newText": newText}}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if content == nil && !hasDiff && text != "" {
|
|
789
|
+
content = textContent(text)
|
|
790
|
+
}
|
|
791
|
+
u := Update{"sessionUpdate": "tool_call_update", "toolCallId": id, "status": status}
|
|
792
|
+
set(u, "content", content)
|
|
793
|
+
if !hasDiff && hasResult {
|
|
794
|
+
u["rawOutput"] = result
|
|
795
|
+
}
|
|
796
|
+
s.emit(u)
|
|
797
|
+
s.cleanupToolCall(id)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
func formatAutoRetryMessage(ev Event) string {
|
|
801
|
+
attempt, ok1 := numberish(ev["attempt"])
|
|
802
|
+
maxAttempts, ok2 := numberish(ev["maxAttempts"])
|
|
803
|
+
delayMs, ok3 := numberish(ev["delayMs"])
|
|
804
|
+
if !ok1 || !ok2 || !ok3 {
|
|
805
|
+
return "Retrying..."
|
|
806
|
+
}
|
|
807
|
+
secs := math.Floor(delayMs/1000 + 0.5)
|
|
808
|
+
if delayMs > 0 && secs == 0 {
|
|
809
|
+
secs = 1
|
|
810
|
+
}
|
|
811
|
+
return fmt.Sprintf("Retrying (attempt %s/%s, waiting %ss)...", jsNumber(attempt), jsNumber(maxAttempts), jsNumber(secs))
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// numberish is Number(v) for the values pig sends: numbers and numeric strings.
|
|
815
|
+
func numberish(v any) (float64, bool) {
|
|
816
|
+
if f, ok := asNumber(v); ok {
|
|
817
|
+
return f, !math.IsInf(f, 0) && !math.IsNaN(f)
|
|
818
|
+
}
|
|
819
|
+
if s, ok := v.(string); ok {
|
|
820
|
+
f, err := strconv.ParseFloat(jsTrim(s), 64)
|
|
821
|
+
if err == nil && !math.IsInf(f, 0) && !math.IsNaN(f) {
|
|
822
|
+
return f, true
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return 0, false
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---- extension dialogs ----
|
|
829
|
+
|
|
830
|
+
func (s *Session) sendUI(resp map[string]any) {
|
|
831
|
+
// A failed acknowledgement leaves nothing to do: the child is gone or will time the dialog out.
|
|
832
|
+
_ = s.proc.SendExtensionUIResponse(resp)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
func (s *Session) handleExtensionUIRequest(ev Event) {
|
|
836
|
+
id, _ := ev["id"].(string)
|
|
837
|
+
method, _ := ev["method"].(string)
|
|
838
|
+
if id == "" {
|
|
839
|
+
return
|
|
840
|
+
}
|
|
841
|
+
cancel := func() { s.ackQ.do(func() { s.sendUI(map[string]any{"id": id, "cancelled": true}) }) }
|
|
842
|
+
switch method {
|
|
843
|
+
case "select":
|
|
844
|
+
go s.handleSelect(ev, id)
|
|
845
|
+
case "confirm":
|
|
846
|
+
go s.handleConfirm(ev, id)
|
|
847
|
+
case "input", "editor":
|
|
848
|
+
s.emit(textChunk("agent_message_chunk", fmt.Sprintf("Pi %s UI request is not supported in ACP yet; cancelling it.", method)))
|
|
849
|
+
cancel()
|
|
850
|
+
case "notify":
|
|
851
|
+
text := "Pi notification"
|
|
852
|
+
if m, ok := ev["message"].(string); ok {
|
|
853
|
+
text = m
|
|
854
|
+
}
|
|
855
|
+
level := "info"
|
|
856
|
+
if l, ok := ev["notifyType"].(string); ok {
|
|
857
|
+
level = l
|
|
858
|
+
}
|
|
859
|
+
u := textChunk("agent_message_chunk", text)
|
|
860
|
+
u["_meta"] = map[string]any{"piAcp": map[string]any{"notify": map[string]any{"level": level}}}
|
|
861
|
+
s.emit(u)
|
|
862
|
+
cancel()
|
|
863
|
+
default:
|
|
864
|
+
cancel()
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
func extensionUIToolCall(id string, ev Event) map[string]any {
|
|
869
|
+
method := "ui"
|
|
870
|
+
if m, ok := ev["method"].(string); ok {
|
|
871
|
+
method = m
|
|
872
|
+
}
|
|
873
|
+
title := "Pi " + method
|
|
874
|
+
if t, ok := ev["title"].(string); ok {
|
|
875
|
+
title = t
|
|
876
|
+
}
|
|
877
|
+
rawInput := map[string]any{"method": method}
|
|
878
|
+
for _, k := range extensionUIRawInputKeys {
|
|
879
|
+
if v, ok := ev[k]; ok {
|
|
880
|
+
rawInput[k] = v
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return map[string]any{"toolCallId": "pi-ui-" + id, "title": title, "kind": "other", "status": "pending", "rawInput": rawInput}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
func (s *Session) requestPermission(id string, ev Event, options []PermissionOption) (PermissionResponse, bool) {
|
|
887
|
+
resp, err := s.conn.RequestPermission(PermissionRequest{SessionID: s.id, ToolCall: extensionUIToolCall(id, ev), Options: options})
|
|
888
|
+
if err != nil {
|
|
889
|
+
s.sendUI(map[string]any{"id": id, "cancelled": true})
|
|
890
|
+
return PermissionResponse{}, false
|
|
891
|
+
}
|
|
892
|
+
return resp, true
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
func optionIndex(optionID string) (int, bool) {
|
|
896
|
+
if !strings.HasPrefix(optionID, choiceOptionPrefix) {
|
|
897
|
+
return 0, false
|
|
898
|
+
}
|
|
899
|
+
raw := optionID[len(choiceOptionPrefix):]
|
|
900
|
+
if raw == "" {
|
|
901
|
+
return 0, false
|
|
902
|
+
}
|
|
903
|
+
n, err := strconv.Atoi(raw)
|
|
904
|
+
if err != nil || n < 0 || strconv.Itoa(n) != raw {
|
|
905
|
+
return 0, false
|
|
906
|
+
}
|
|
907
|
+
return n, true
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
func (s *Session) handleSelect(ev Event, id string) {
|
|
911
|
+
raw, _ := ev["options"].([]any)
|
|
912
|
+
options := make([]string, 0, len(raw))
|
|
913
|
+
for _, o := range raw {
|
|
914
|
+
options = append(options, jsString(o))
|
|
915
|
+
}
|
|
916
|
+
if len(options) == 0 {
|
|
917
|
+
s.sendUI(map[string]any{"id": id, "cancelled": true})
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
perm := make([]PermissionOption, len(options))
|
|
921
|
+
for i, name := range options {
|
|
922
|
+
perm[i] = PermissionOption{OptionID: fmt.Sprintf("%s%d", choiceOptionPrefix, i), Name: name, Kind: "allow_once"}
|
|
923
|
+
}
|
|
924
|
+
resp, ok := s.requestPermission(id, ev, perm)
|
|
925
|
+
if !ok {
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
if resp.Outcome.Outcome == "selected" {
|
|
929
|
+
if idx, ok := optionIndex(resp.Outcome.OptionID); ok && idx < len(options) {
|
|
930
|
+
s.sendUI(map[string]any{"id": id, "value": options[idx]})
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
s.sendUI(map[string]any{"id": id, "cancelled": true})
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
func (s *Session) handleConfirm(ev Event, id string) {
|
|
938
|
+
resp, ok := s.requestPermission(id, ev, []PermissionOption{
|
|
939
|
+
{OptionID: "yes", Name: "Yes", Kind: "allow_once"},
|
|
940
|
+
{OptionID: "no", Name: "No", Kind: "reject_once"},
|
|
941
|
+
})
|
|
942
|
+
if !ok {
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
if resp.Outcome.Outcome == "cancelled" {
|
|
946
|
+
s.sendUI(map[string]any{"id": id, "cancelled": true})
|
|
947
|
+
return
|
|
948
|
+
}
|
|
949
|
+
s.sendUI(map[string]any{"id": id, "confirmed": resp.Outcome.OptionID == "yes"})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ---- session registry ----
|
|
953
|
+
|
|
954
|
+
// SessionManager is the default SessionRegistry.
|
|
955
|
+
type SessionManager struct {
|
|
956
|
+
spawn SpawnFunc
|
|
957
|
+
store Store
|
|
958
|
+
|
|
959
|
+
mu sync.Mutex
|
|
960
|
+
sessions map[string]*Session
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// NewSessionManager returns an empty manager.
|
|
964
|
+
func NewSessionManager(spawn SpawnFunc, store Store) *SessionManager {
|
|
965
|
+
if spawn == nil {
|
|
966
|
+
spawn = func(p SpawnParams) (Proc, error) {
|
|
967
|
+
proc, err := pirpc.SpawnWithArgs(p.Cwd, p.PiCommand, p.SessionPath, nil)
|
|
968
|
+
if err != nil {
|
|
969
|
+
return nil, err
|
|
970
|
+
}
|
|
971
|
+
return proc, nil
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return &SessionManager{spawn: spawn, store: store, sessions: map[string]*Session{}}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Create spawns a child and registers the session. pig manages persistence in its default
|
|
978
|
+
// location, so the sessions stay visible to the regular `pig` CLI.
|
|
979
|
+
func (m *SessionManager) Create(p SessionCreateParams) (ActiveSession, error) {
|
|
980
|
+
proc, err := m.spawn(SpawnParams{Cwd: p.Cwd, PiCommand: p.PiCommand})
|
|
981
|
+
if err != nil {
|
|
982
|
+
var sc interface{ SpawnCode() string }
|
|
983
|
+
if errors.As(err, &sc) {
|
|
984
|
+
data := map[string]any{}
|
|
985
|
+
if c := sc.SpawnCode(); c != "" {
|
|
986
|
+
data["code"] = c
|
|
987
|
+
}
|
|
988
|
+
return nil, ErrInternal(data, err.Error())
|
|
989
|
+
}
|
|
990
|
+
return nil, err
|
|
991
|
+
}
|
|
992
|
+
state, serr := proc.GetState()
|
|
993
|
+
id, file := "", ""
|
|
994
|
+
if serr == nil {
|
|
995
|
+
id, _ = state["sessionId"].(string)
|
|
996
|
+
file, _ = state["sessionFile"].(string)
|
|
997
|
+
}
|
|
998
|
+
if id == "" {
|
|
999
|
+
id = newUUID()
|
|
1000
|
+
}
|
|
1001
|
+
if file != "" {
|
|
1002
|
+
m.store.Upsert(StoredSession{SessionID: id, Cwd: p.Cwd, SessionFile: file})
|
|
1003
|
+
}
|
|
1004
|
+
sess := NewSession(SessionOptions{SessionID: id, Cwd: p.Cwd, McpServers: p.McpServers, Proc: proc, Conn: p.Conn, FileCommands: p.FileCommands})
|
|
1005
|
+
m.mu.Lock()
|
|
1006
|
+
m.sessions[id] = sess
|
|
1007
|
+
m.mu.Unlock()
|
|
1008
|
+
return sess, nil
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// MaybeGet returns a registered session or nil.
|
|
1012
|
+
func (m *SessionManager) MaybeGet(id string) ActiveSession {
|
|
1013
|
+
m.mu.Lock()
|
|
1014
|
+
defer m.mu.Unlock()
|
|
1015
|
+
if s, ok := m.sessions[id]; ok {
|
|
1016
|
+
return s
|
|
1017
|
+
}
|
|
1018
|
+
return nil
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Get returns a registered session or an invalid-params error.
|
|
1022
|
+
func (m *SessionManager) Get(id string) (ActiveSession, error) {
|
|
1023
|
+
if s := m.MaybeGet(id); s != nil {
|
|
1024
|
+
return s, nil
|
|
1025
|
+
}
|
|
1026
|
+
return nil, ErrInvalidParams(nil, "Unknown sessionId: "+id)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// GetOrCreate registers a session around an existing child (session/load).
|
|
1030
|
+
func (m *SessionManager) GetOrCreate(id string, p SessionCreateParams) ActiveSession {
|
|
1031
|
+
m.mu.Lock()
|
|
1032
|
+
defer m.mu.Unlock()
|
|
1033
|
+
if s, ok := m.sessions[id]; ok {
|
|
1034
|
+
return s
|
|
1035
|
+
}
|
|
1036
|
+
sess := NewSession(SessionOptions{SessionID: id, Cwd: p.Cwd, McpServers: p.McpServers, Proc: p.Proc, Conn: p.Conn, FileCommands: p.FileCommands})
|
|
1037
|
+
m.sessions[id] = sess
|
|
1038
|
+
return sess
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Close disposes a session's child and forgets the session.
|
|
1042
|
+
func (m *SessionManager) Close(id string) {
|
|
1043
|
+
m.mu.Lock()
|
|
1044
|
+
s, ok := m.sessions[id]
|
|
1045
|
+
delete(m.sessions, id)
|
|
1046
|
+
m.mu.Unlock()
|
|
1047
|
+
if ok {
|
|
1048
|
+
s.proc.Dispose()
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// CloseAllExcept disposes every other session.
|
|
1053
|
+
func (m *SessionManager) CloseAllExcept(keep string) {
|
|
1054
|
+
m.mu.Lock()
|
|
1055
|
+
var ids []string
|
|
1056
|
+
for id := range m.sessions {
|
|
1057
|
+
if id != keep {
|
|
1058
|
+
ids = append(ids, id)
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
m.mu.Unlock()
|
|
1062
|
+
for _, id := range ids {
|
|
1063
|
+
m.Close(id)
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// DisposeAll disposes every session.
|
|
1068
|
+
func (m *SessionManager) DisposeAll() { m.CloseAllExcept("") }
|