@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.
Files changed (134) hide show
  1. package/CREDITS.md +23 -0
  2. package/LICENSE +22 -0
  3. package/README.md +110 -0
  4. package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
  5. package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
  6. package/extensions/acp/cmd/pig-acp/go.mod +6 -0
  7. package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
  8. package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
  9. package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
  10. package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
  11. package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
  12. package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
  13. package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
  14. package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
  15. package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
  16. package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
  17. package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
  18. package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
  19. package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
  20. package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
  21. package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
  22. package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
  23. package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
  24. package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
  25. package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
  26. package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
  27. package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
  28. package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
  29. package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
  30. package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
  31. package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
  32. package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
  33. package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
  34. package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
  35. package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
  36. package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
  37. package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
  38. package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
  39. package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
  40. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
  41. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
  42. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
  43. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
  44. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
  45. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
  46. package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
  47. package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
  48. package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
  49. package/extensions/acp/cmd/pig-acp/main.go +177 -0
  50. package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
  51. package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
  52. package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
  53. package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
  54. package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
  55. package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
  56. package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
  57. package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
  58. package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
  59. package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
  60. package/extensions/acp/extension.go +63 -0
  61. package/extensions/acp/extension_test.go +111 -0
  62. package/extensions/acp/fakehost_test.go +548 -0
  63. package/extensions/acp/go.mod +6 -0
  64. package/extensions/acp/go.sum +2 -0
  65. package/package.json +41 -0
  66. package/port/PORT.md +147 -0
  67. package/port/interop/package-lock.json +89 -0
  68. package/port/interop/package.json +13 -0
  69. package/port/interop/run.mjs +229 -0
  70. package/port/mutate.mjs +110 -0
  71. package/port/mutation-results.txt +107 -0
  72. package/port/mutations.json +748 -0
  73. package/port/oracle/pi-acp/LICENSE +21 -0
  74. package/port/oracle/pi-acp/README.md +209 -0
  75. package/port/oracle/pi-acp/package.json +65 -0
  76. package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
  77. package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
  78. package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
  79. package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
  80. package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
  81. package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
  82. package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
  83. package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
  84. package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
  85. package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
  86. package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
  87. package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
  88. package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
  89. package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
  90. package/port/oracle/pi-acp/src/index.ts +82 -0
  91. package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
  92. package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
  93. package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
  94. package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
  95. package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
  96. package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
  97. package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
  98. package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
  99. package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
  100. package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
  101. package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
  102. package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
  103. package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
  104. package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
  105. package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
  106. package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
  107. package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
  108. package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
  109. package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
  110. package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
  111. package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
  112. package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
  113. package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
  114. package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
  115. package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
  116. package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
  117. package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
  118. package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
  119. package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
  120. package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
  121. package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
  122. package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
  123. package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
  124. package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
  125. package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
  126. package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
  127. package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
  128. package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
  129. package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
  130. package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
  131. package/port/schema/LICENSE +191 -0
  132. package/port/schema/schema.json +8015 -0
  133. package/port/upstream-tests.json +201 -0
  134. 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("") }