@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,784 @@
1
+ package acp
2
+
3
+ import (
4
+ "errors"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "regexp"
9
+ "strconv"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+
14
+ "github.com/MichaelKinsy/pigpen/acp/cmd/pig-acp/internal/pirpc"
15
+ )
16
+
17
+ // Version is the adapter version reported in agentInfo.
18
+ var Version = "0.1.0"
19
+
20
+ const (
21
+ modelConfigID = "model"
22
+ thoughtLevelConfigID = "thought_level"
23
+ listPageSize = 50
24
+ maxChangelogChars = 20000
25
+ )
26
+
27
+ // Agent implements the ACP agent methods over pig RPC children (PiAcpAgent of the original).
28
+ type Agent struct {
29
+ *agentState
30
+ // schedule runs fn once the response of the request being handled has been sent
31
+ // (the original's setTimeout(fn, 0) after session/new and session/load).
32
+ schedule func(fn func())
33
+ }
34
+
35
+ // agentState is shared by the per-request views of an Agent.
36
+ type agentState struct {
37
+ conn Conn
38
+ sessions SessionRegistry
39
+ store Store
40
+ spawn SpawnFunc
41
+
42
+ // piCommand and piArgs select the pig executable and extra arguments (--piglet ...).
43
+ piCommand string
44
+ piArgs []string
45
+
46
+ mu sync.Mutex
47
+ lastSessionCwd string
48
+ restoreMu sync.Mutex
49
+
50
+ // changelogPath finds the changelog for /changelog (a test seam).
51
+ changelogPath func() string
52
+ }
53
+
54
+ // Scoped returns a view of the agent whose deferred work goes to schedule.
55
+ func (a *Agent) Scoped(schedule func(fn func())) *Agent {
56
+ c := *a
57
+ c.schedule = schedule
58
+ return &c
59
+ }
60
+
61
+ // NewAgent returns an agent that talks to the client over conn. Its sessions start
62
+ // `pig --mode rpc` children and are recorded in the session map under PIG_HOME.
63
+ func NewAgent(conn Conn) *Agent {
64
+ st := &agentState{conn: conn}
65
+ st.store = NewFileStore("")
66
+ st.spawn = st.defaultSpawn
67
+ st.sessions = NewSessionManager(st.spawn, st.store)
68
+ return &Agent{agentState: st}
69
+ }
70
+
71
+ // Configure selects the pig executable ("" = the default) and extra pig arguments.
72
+ func (a *Agent) Configure(piCommand string, piArgs []string) {
73
+ a.piCommand, a.piArgs = piCommand, piArgs
74
+ }
75
+
76
+ func (st *agentState) defaultSpawn(p SpawnParams) (Proc, error) {
77
+ proc, err := pirpc.SpawnWithArgs(p.Cwd, p.PiCommand, p.SessionPath, st.piArgs)
78
+ if err != nil {
79
+ return nil, err
80
+ }
81
+ return proc, nil
82
+ }
83
+
84
+ // pigCommand is the executable to start: the configured one, else the environment override.
85
+ func (st *agentState) pigCommand() string {
86
+ if st.piCommand != "" {
87
+ return st.piCommand
88
+ }
89
+ return envFlag("PIG_ACP_PIG_COMMAND", "PI_ACP_PI_COMMAND")
90
+ }
91
+
92
+ func (a *Agent) defer_(fn func()) {
93
+ if a.schedule != nil {
94
+ a.schedule(fn)
95
+ return
96
+ }
97
+ go fn()
98
+ }
99
+
100
+ // Dispose kills every session's pig child.
101
+ func (a *Agent) Dispose() { a.sessions.DisposeAll() }
102
+
103
+ func envFlag(names ...string) string {
104
+ for _, n := range names {
105
+ if v := os.Getenv(n); v != "" {
106
+ return v
107
+ }
108
+ }
109
+ return ""
110
+ }
111
+
112
+ // Initialize negotiates the protocol version and advertises only what is implemented.
113
+ func (a *Agent) Initialize(req InitializeRequest) (InitializeResponse, error) {
114
+ terminalAuth := false
115
+ if meta, ok := req.ClientCapabilities["_meta"].(map[string]any); ok {
116
+ terminalAuth = meta["terminal-auth"] == true
117
+ }
118
+ return InitializeResponse{
119
+ // Only protocol version 1 is spoken: a client asking for another gets 1 and decides.
120
+ ProtocolVersion: ProtocolVersion,
121
+ AgentInfo: map[string]any{"name": "pig-acp", "title": "PiG ACP adapter", "version": Version},
122
+ AuthMethods: AuthMethods(terminalAuth),
123
+ AgentCapabilities: AgentCapabilities{
124
+ LoadSession: true,
125
+ McpCapabilities: McpCapabilities{HTTP: false, SSE: false},
126
+ PromptCapabilities: PromptCapabilities{
127
+ Image: true, Audio: false,
128
+ EmbeddedContext: envFlag("PIG_ACP_ENABLE_EMBEDDED_CONTEXT", "PI_ACP_ENABLE_EMBEDDED_CONTEXT") == "true",
129
+ },
130
+ SessionCapabilities: SessionCapabilities{List: map[string]any{}, Delete: map[string]any{}},
131
+ },
132
+ }, nil
133
+ }
134
+
135
+ func (a *Agent) setLastCwd(cwd string) {
136
+ a.mu.Lock()
137
+ a.lastSessionCwd = cwd
138
+ a.mu.Unlock()
139
+ }
140
+
141
+ func (a *Agent) getLastCwd() string {
142
+ a.mu.Lock()
143
+ defer a.mu.Unlock()
144
+ return a.lastSessionCwd
145
+ }
146
+
147
+ func (a *Agent) cleanupFailedNewSession(sessionID string, state map[string]any) {
148
+ a.sessions.Close(sessionID)
149
+ file := ""
150
+ if f, ok := state["sessionFile"].(string); ok && jsTrim(f) != "" {
151
+ file = f
152
+ } else if e := a.store.Get(sessionID); e != nil {
153
+ file = e.SessionFile
154
+ }
155
+ if jsTrim(file) != "" {
156
+ // Best effort: the auth or internal error is the primary result.
157
+ _ = os.Remove(file)
158
+ }
159
+ a.store.Delete(sessionID)
160
+ }
161
+
162
+ type storedRef struct{ cwd, sessionFile string }
163
+
164
+ func (a *Agent) findStoredSession(sessionID string) *storedRef {
165
+ if e := a.store.Get(sessionID); e != nil && e.Cwd != "" && e.SessionFile != "" {
166
+ return &storedRef{e.Cwd, e.SessionFile}
167
+ }
168
+ ps := FindPiSession(sessionID)
169
+ if ps == nil {
170
+ return nil
171
+ }
172
+ a.store.Upsert(StoredSession{SessionID: sessionID, Cwd: ps.Cwd, SessionFile: ps.SessionFile})
173
+ return &storedRef{ps.Cwd, ps.SessionFile}
174
+ }
175
+
176
+ func spawnErrorToRequestError(err error) error {
177
+ var sc interface{ SpawnCode() string }
178
+ if errors.As(err, &sc) {
179
+ data := map[string]any{}
180
+ if c := sc.SpawnCode(); c != "" {
181
+ data["code"] = c
182
+ }
183
+ return ErrInternal(data, err.Error())
184
+ }
185
+ return err
186
+ }
187
+
188
+ // restoreSession returns the live session, or starts a child around the stored session file.
189
+ func (a *Agent) restoreSession(sessionID string, cwd string, mcp []any) (ActiveSession, error) {
190
+ // One restore at a time: a second request for the same session waits, then finds it live.
191
+ a.restoreMu.Lock()
192
+ defer a.restoreMu.Unlock()
193
+ if s := a.sessions.MaybeGet(sessionID); s != nil {
194
+ return s, nil
195
+ }
196
+ stored := a.findStoredSession(sessionID)
197
+ if stored == nil {
198
+ return nil, ErrInvalidParams(nil, "Unknown sessionId: "+sessionID)
199
+ }
200
+ if cwd == "" {
201
+ cwd = stored.cwd
202
+ }
203
+ proc, err := a.spawn(SpawnParams{Cwd: cwd, SessionPath: stored.sessionFile, PiCommand: a.pigCommand()})
204
+ if err != nil {
205
+ return nil, spawnErrorToRequestError(err)
206
+ }
207
+ if mcp == nil {
208
+ mcp = []any{}
209
+ }
210
+ sess := a.sessions.GetOrCreate(sessionID, SessionCreateParams{Cwd: cwd, McpServers: mcp, Conn: a.conn, Proc: proc, FileCommands: LoadSlashCommands(cwd)})
211
+ a.setLastCwd(cwd)
212
+ a.store.Upsert(StoredSession{SessionID: sessionID, Cwd: cwd, SessionFile: stored.sessionFile})
213
+ return sess, nil
214
+ }
215
+
216
+ // ---- session configuration (models, thinking levels) ----
217
+
218
+ type sessionConfiguration struct {
219
+ ConfigOptions []ConfigOption
220
+ Models *ModelState
221
+ Modes ModeState
222
+ }
223
+
224
+ func getThinkingState(proc Proc, state map[string]any) (ModeState, error) {
225
+ if state == nil {
226
+ var err error
227
+ if state, err = proc.GetState(); err != nil {
228
+ return ModeState{}, err
229
+ }
230
+ }
231
+ available, err := proc.GetAvailableThinkingLevels()
232
+ if err != nil {
233
+ return ModeState{}, err
234
+ }
235
+ current, _ := state["thinkingLevel"].(string)
236
+ found := false
237
+ for _, l := range available {
238
+ if l == current {
239
+ found = true
240
+ }
241
+ }
242
+ if current == "" || !found {
243
+ return ModeState{}, errors.New("pi returned a thinking level absent from available levels")
244
+ }
245
+ modes := make([]Mode, len(available))
246
+ for i, id := range available {
247
+ modes[i] = Mode{ID: id, Name: "Thinking: " + id}
248
+ }
249
+ return ModeState{CurrentModeID: current, AvailableModes: modes}, nil
250
+ }
251
+
252
+ func getModelState(proc Proc, state, data map[string]any, haveState, haveModels bool) *ModelState {
253
+ if !haveModels {
254
+ if d, err := proc.GetAvailableModels(); err == nil {
255
+ data = d
256
+ } else {
257
+ data = nil
258
+ }
259
+ }
260
+ raw, _ := data["models"].([]any)
261
+ available := []AdvertisedModel{}
262
+ for _, item := range raw {
263
+ m := asObject(item)
264
+ provider := jsTrim(jsStr(m["provider"], ""))
265
+ id := jsTrim(jsStr(m["id"], ""))
266
+ if provider == "" || id == "" {
267
+ continue
268
+ }
269
+ name := jsStr(m["name"], id)
270
+ available = append(available, AdvertisedModel{ModelID: provider + "/" + id, Name: provider + "/" + name})
271
+ }
272
+ if !haveState {
273
+ if s, err := proc.GetState(); err == nil {
274
+ state = s
275
+ } else {
276
+ state = nil
277
+ }
278
+ }
279
+ current := ""
280
+ if model := asObject(state["model"]); model != nil {
281
+ provider := jsTrim(jsStr(model["provider"], ""))
282
+ id := jsTrim(jsStr(model["id"], ""))
283
+ if provider != "" && id != "" {
284
+ current = provider + "/" + id
285
+ }
286
+ }
287
+ if len(available) == 0 && current == "" {
288
+ return nil
289
+ }
290
+ if current == "" {
291
+ current = "default"
292
+ if len(available) > 0 {
293
+ current = available[0].ModelID
294
+ }
295
+ }
296
+ return &ModelState{AvailableModels: available, CurrentModelID: current}
297
+ }
298
+
299
+ func buildConfigOptions(models *ModelState, modes ModeState) []ConfigOption {
300
+ thought := ConfigOption{Type: "select", ID: thoughtLevelConfigID, Category: "thought_level", Name: "Thinking",
301
+ Description: "Set the reasoning effort for this session", CurrentValue: modes.CurrentModeID}
302
+ for _, m := range modes.AvailableModes {
303
+ thought.Options = append(thought.Options, ConfigSelectOption{Value: m.ID, Name: m.Name, Description: m.Description})
304
+ }
305
+ out := []ConfigOption{thought}
306
+ if models != nil && len(models.AvailableModels) > 0 {
307
+ model := ConfigOption{Type: "select", ID: modelConfigID, Category: "model", Name: "Model",
308
+ Description: "Select the model for this session", CurrentValue: models.CurrentModelID}
309
+ for _, m := range models.AvailableModels {
310
+ model.Options = append(model.Options, ConfigSelectOption{Value: m.ModelID, Name: m.Name, Description: m.Description})
311
+ }
312
+ out = append([]ConfigOption{model}, out...)
313
+ }
314
+ return out
315
+ }
316
+
317
+ // getSessionConfiguration reads models and thinking levels. pre carries values the caller already
318
+ // has (state, models); nil means "ask pig".
319
+ func getSessionConfiguration(proc Proc, state, models map[string]any, haveModels bool) (sessionConfiguration, error) {
320
+ haveState := state != nil
321
+ if !haveState {
322
+ s, err := proc.GetState()
323
+ if err != nil {
324
+ return sessionConfiguration{}, err
325
+ }
326
+ state, haveState = s, true
327
+ }
328
+ ms := getModelState(proc, state, models, haveState, haveModels)
329
+ modes, err := getThinkingState(proc, state)
330
+ if err != nil {
331
+ return sessionConfiguration{}, err
332
+ }
333
+ return sessionConfiguration{ConfigOptions: buildConfigOptions(ms, modes), Models: ms, Modes: modes}, nil
334
+ }
335
+
336
+ func (a *Agent) emitConfigOptionsUpdate(sessionID string, proc Proc) ([]ConfigOption, error) {
337
+ cfg, err := getSessionConfiguration(proc, nil, nil, false)
338
+ if err != nil {
339
+ return nil, err
340
+ }
341
+ if err := a.conn.SessionUpdate(sessionID, Update{"sessionUpdate": "current_mode_update", "currentModeId": cfg.Modes.CurrentModeID}); err != nil {
342
+ return nil, err
343
+ }
344
+ if err := a.conn.SessionUpdate(sessionID, Update{"sessionUpdate": "config_option_update", "configOptions": cfg.ConfigOptions}); err != nil {
345
+ return nil, err
346
+ }
347
+ return cfg.ConfigOptions, nil
348
+ }
349
+
350
+ func setSessionModel(proc Proc, requested string) error {
351
+ provider, modelID := "", ""
352
+ if strings.Contains(requested, "/") {
353
+ parts := strings.Split(requested, "/")
354
+ provider, modelID = parts[0], strings.Join(parts[1:], "/")
355
+ } else {
356
+ modelID = requested
357
+ }
358
+ if provider == "" {
359
+ data, err := proc.GetAvailableModels()
360
+ if err != nil {
361
+ return err
362
+ }
363
+ models, _ := data["models"].([]any)
364
+ for _, item := range models {
365
+ m := asObject(item)
366
+ if jsString(m["id"]) == modelID {
367
+ provider, modelID = jsString(m["provider"]), jsString(m["id"])
368
+ break
369
+ }
370
+ }
371
+ }
372
+ if provider == "" || modelID == "" {
373
+ return ErrInvalidParams(nil, "Unknown modelId: "+requested)
374
+ }
375
+ return proc.SetModel(provider, modelID)
376
+ }
377
+
378
+ // ---- session lifecycle ----
379
+
380
+ func (a *Agent) commandsTask(sess ActiveSession, enableSkills bool, fileCommands []FileSlashCommand) func() {
381
+ return func() {
382
+ // Publish real context usage now that the client knows the session id (clients ignore
383
+ // notifications for unknown sessions), so the window size is right before the first prompt.
384
+ sess.PublishContextUsage()
385
+ var commands []AvailableCommand
386
+ if data, err := sess.Proc().GetCommands(); err == nil {
387
+ commands = ToAvailableCommandsFromPiGetCommands(data, PiCommandsOptions{EnableSkillCommands: enableSkills})
388
+ } else {
389
+ // Fall back to the prompt template files.
390
+ commands = ToAvailableCommands(fileCommands)
391
+ }
392
+ _ = a.conn.SessionUpdate(sess.ID(), Update{"sessionUpdate": "available_commands_update", "availableCommands": MergeCommands(commands, BuiltinAvailableCommands())})
393
+ }
394
+ }
395
+
396
+ func messageOf(err error) string {
397
+ if err == nil {
398
+ return ""
399
+ }
400
+ return err.Error()
401
+ }
402
+
403
+ func authRequired() *RequestError {
404
+ return ErrAuthRequired(map[string]any{"authMethods": AuthMethods(true)}, "Configure an API key or log in with an OAuth provider.")
405
+ }
406
+
407
+ // NewSession starts a pig child for a new session.
408
+ func (a *Agent) NewSession(req NewSessionRequest) (NewSessionResponse, error) {
409
+ if !filepath.IsAbs(req.Cwd) {
410
+ return NewSessionResponse{}, ErrInvalidParams(nil, "cwd must be an absolute path: "+req.Cwd)
411
+ }
412
+ a.setLastCwd(req.Cwd)
413
+ fileCommands := LoadSlashCommands(req.Cwd)
414
+ enableSkills := GetEnableSkillCommands(req.Cwd)
415
+
416
+ // pig has no MCP client inside this adapter: servers are accepted and stored, not started.
417
+ sess, err := a.sessions.Create(SessionCreateParams{Cwd: req.Cwd, McpServers: req.McpServers, Conn: a.conn, FileCommands: fileCommands, PiCommand: a.pigCommand()})
418
+ if err != nil {
419
+ return NewSessionResponse{}, err
420
+ }
421
+ proc := sess.Proc()
422
+
423
+ // State and models once, in parallel, to cut startup latency.
424
+ var state, models map[string]any
425
+ var stateErr, modelsErr error
426
+ var wg sync.WaitGroup
427
+ wg.Add(2)
428
+ go func() { defer wg.Done(); state, stateErr = proc.GetState() }()
429
+ go func() { defer wg.Done(); models, modelsErr = proc.GetAvailableModels() }()
430
+ wg.Wait()
431
+ if stateErr != nil {
432
+ state = nil
433
+ }
434
+
435
+ if modelsErr != nil {
436
+ a.cleanupFailedNewSession(sess.ID(), state)
437
+ if ae := MaybeAuthRequiredError(modelsErr); ae != nil {
438
+ return NewSessionResponse{}, ae
439
+ }
440
+ return NewSessionResponse{}, ErrInternal(map[string]any{}, messageOf(modelsErr))
441
+ }
442
+ // No models after spawning means pig has no credentials.
443
+ if raw, _ := models["models"].([]any); len(raw) == 0 {
444
+ a.cleanupFailedNewSession(sess.ID(), state)
445
+ return NewSessionResponse{}, authRequired()
446
+ }
447
+ if stateErr != nil && MaybeAuthRequiredError(stateErr) != nil {
448
+ a.cleanupFailedNewSession(sess.ID(), state)
449
+ return NewSessionResponse{}, authRequired()
450
+ }
451
+ var cfg sessionConfiguration
452
+ if stateErr != nil {
453
+ err = stateErr
454
+ } else {
455
+ cfg, err = getSessionConfiguration(proc, state, models, true)
456
+ }
457
+ if err != nil {
458
+ a.cleanupFailedNewSession(sess.ID(), state)
459
+ if ae := MaybeAuthRequiredError(err); ae != nil {
460
+ return NewSessionResponse{}, ae
461
+ }
462
+ return NewSessionResponse{}, ErrInternal(map[string]any{}, messageOf(err))
463
+ }
464
+
465
+ // PiG has no npm update check (`pig update` uses a signed manifest), so quietStartup simply
466
+ // suppresses the startup block: see PORT.md, gap A18.
467
+ prelude := ""
468
+ if !GetQuietStartup(req.Cwd) {
469
+ prelude = BuildStartupInfo(req.Cwd, a.pigCommand())
470
+ }
471
+ if prelude != "" {
472
+ sess.SetStartupInfo(prelude)
473
+ }
474
+
475
+ // Within one ACP connection (one editor window) only one live pig child is kept, so a client
476
+ // that starts new sessions without closing old ones does not leak processes.
477
+ a.sessions.CloseAllExcept(sess.ID())
478
+
479
+ var startup any
480
+ if prelude != "" {
481
+ startup = prelude
482
+ }
483
+ resp := NewSessionResponse{SessionID: sess.ID(), ConfigOptions: cfg.ConfigOptions, Models: cfg.Models, Modes: cfg.Modes,
484
+ Meta: map[string]any{"piAcp": map[string]any{"startupInfo": startup}}}
485
+
486
+ // Sent after the response: some clients ignore notifications for a session id they have not seen.
487
+ if prelude != "" {
488
+ a.defer_(sess.SendStartupInfoIfPending)
489
+ }
490
+ a.defer_(a.commandsTask(sess, enableSkills, fileCommands))
491
+ return resp, nil
492
+ }
493
+
494
+ // Authenticate is a successful no-op: sign-in is the terminal login (`pig-acp --terminal-login`).
495
+ func (a *Agent) Authenticate(req AuthenticateRequest) error { return nil }
496
+
497
+ // Cancel aborts the running turn of a live session; unknown ids are ignored, never restored.
498
+ func (a *Agent) Cancel(sessionID string) error {
499
+ s := a.sessions.MaybeGet(sessionID)
500
+ if s == nil {
501
+ return nil
502
+ }
503
+ return s.Cancel()
504
+ }
505
+
506
+ // ListSessions lists the PiG sessions on disk, scoped to a cwd (the request's, else the last used:
507
+ // Zed sends none, and pi's /resume picker is project scoped).
508
+ func (a *Agent) ListSessions(req ListSessionsRequest) (ListSessionsResponse, error) {
509
+ all := ListPiSessions()
510
+ cwd := a.getLastCwd()
511
+ if req.Cwd != nil {
512
+ cwd = *req.Cwd
513
+ }
514
+ filtered := all
515
+ if cwd != "" {
516
+ filtered = nil
517
+ for _, s := range all {
518
+ if s.Cwd == cwd {
519
+ filtered = append(filtered, s)
520
+ }
521
+ }
522
+ }
523
+ start := 0
524
+ if req.Cursor != nil && *req.Cursor != "" {
525
+ if n, ok := parseIntPrefix(*req.Cursor); ok && n > 0 {
526
+ start = n
527
+ }
528
+ }
529
+ end := start + listPageSize
530
+ if start > len(filtered) {
531
+ start = len(filtered)
532
+ }
533
+ if end > len(filtered) {
534
+ end = len(filtered)
535
+ }
536
+ sessions := []SessionInfo{}
537
+ for _, s := range filtered[start:end] {
538
+ sessions = append(sessions, SessionInfo{SessionID: s.SessionID, Cwd: s.Cwd, Title: s.Title, UpdatedAt: s.UpdatedAt})
539
+ }
540
+ var next *string
541
+ if start+listPageSize < len(filtered) {
542
+ n := strconv.Itoa(start + listPageSize)
543
+ next = &n
544
+ }
545
+ return ListSessionsResponse{Sessions: sessions, NextCursor: next, Meta: map[string]any{}}, nil
546
+ }
547
+
548
+ var intPrefix = regexp.MustCompile(`^\s*[+-]?\d+`)
549
+
550
+ // parseIntPrefix is Number.parseInt(s, 10) without the NaN: ok is false for no digits.
551
+ func parseIntPrefix(s string) (int, bool) {
552
+ m := intPrefix.FindString(s)
553
+ if m == "" {
554
+ return 0, false
555
+ }
556
+ n, err := strconv.Atoi(strings.TrimSpace(m))
557
+ return n, err == nil
558
+ }
559
+
560
+ func (a *Agent) send(sessionID string, u Update) error { return a.conn.SessionUpdate(sessionID, u) }
561
+
562
+ // LoadSession reattaches to a stored session and replays its history.
563
+ func (a *Agent) LoadSession(req LoadSessionRequest) (LoadSessionResponse, error) {
564
+ if !filepath.IsAbs(req.Cwd) {
565
+ return LoadSessionResponse{}, ErrInvalidParams(nil, "cwd must be an absolute path: "+req.Cwd)
566
+ }
567
+ // A client that reloads a live session gets a fresh child, and its commands advertised again.
568
+ a.sessions.Close(req.SessionID)
569
+ a.setLastCwd(req.Cwd)
570
+ stored := a.findStoredSession(req.SessionID)
571
+ if stored == nil {
572
+ return LoadSessionResponse{}, ErrInvalidParams(nil, "Unknown sessionId: "+req.SessionID)
573
+ }
574
+ enableSkills := GetEnableSkillCommands(req.Cwd)
575
+ sess, err := a.restoreSession(req.SessionID, req.Cwd, req.McpServers)
576
+ if err != nil {
577
+ return LoadSessionResponse{}, err
578
+ }
579
+ proc := sess.Proc()
580
+ cfg, err := getSessionConfiguration(proc, nil, nil, false)
581
+ if err != nil {
582
+ // The restored child is closed; the stored history and map entry stay as they were.
583
+ a.sessions.Close(sess.ID())
584
+ return LoadSessionResponse{}, err
585
+ }
586
+ fileCommands := LoadSlashCommands(req.Cwd)
587
+ a.sessions.CloseAllExcept(sess.ID())
588
+ a.store.Upsert(StoredSession{SessionID: req.SessionID, Cwd: req.Cwd, SessionFile: stored.sessionFile})
589
+
590
+ data, err := proc.GetMessages()
591
+ if err != nil {
592
+ return LoadSessionResponse{}, err
593
+ }
594
+ messages, _ := data["messages"].([]any)
595
+ for _, item := range messages {
596
+ if err := a.replayMessage(sess.ID(), req.Cwd, asObject(item), item); err != nil {
597
+ return LoadSessionResponse{}, err
598
+ }
599
+ }
600
+
601
+ a.defer_(a.commandsTask(sess, enableSkills, fileCommands))
602
+ return LoadSessionResponse{ConfigOptions: cfg.ConfigOptions, Models: cfg.Models, Modes: cfg.Modes,
603
+ Meta: map[string]any{"piAcp": map[string]any{"startupInfo": nil}}}, nil
604
+ }
605
+
606
+ func (a *Agent) replayMessage(sessionID, cwd string, m map[string]any, raw any) error {
607
+ switch jsStr(m["role"], "") {
608
+ case "user":
609
+ if text := NormalizePiMessageText(m["content"]); text != "" {
610
+ return a.send(sessionID, textChunk("user_message_chunk", text))
611
+ }
612
+ case "assistant":
613
+ if text := NormalizePiAssistantText(m["content"]); text != "" {
614
+ return a.send(sessionID, textChunk("agent_message_chunk", text))
615
+ }
616
+ case "toolResult":
617
+ toolName := jsStr(m["toolName"], "tool")
618
+ id := newUUID()
619
+ if v, ok := m["toolCallId"]; ok && v != nil {
620
+ id = jsString(v)
621
+ }
622
+ isError := truthy(m["isError"])
623
+ status := "completed"
624
+ if isError {
625
+ status = "failed"
626
+ }
627
+ if IsBashTool(toolName) {
628
+ text := BashResultText(m)
629
+ title := toolName
630
+ if c, ok := BashCommand(m); ok {
631
+ title = c
632
+ }
633
+ if err := a.send(sessionID, Update{"sessionUpdate": "tool_call", "toolCallId": id, "title": title, "kind": "execute",
634
+ "status": "completed", "content": BashTerminalContent(id), "_meta": BashTerminalInfoMeta(id, cwd)}); err != nil {
635
+ return err
636
+ }
637
+ meta := map[string]any{}
638
+ if text != "" {
639
+ for k, v := range BashTerminalOutputMeta(id, text) {
640
+ meta[k] = v
641
+ }
642
+ }
643
+ for k, v := range BashTerminalExitMeta(id, BashExitCode(m, isError)) {
644
+ meta[k] = v
645
+ }
646
+ return a.send(sessionID, Update{"sessionUpdate": "tool_call_update", "toolCallId": id, "status": status, "_meta": meta})
647
+ }
648
+ // A synthetic tool call renders historic tool use.
649
+ if err := a.send(sessionID, Update{"sessionUpdate": "tool_call", "toolCallId": id, "title": toolName, "kind": toToolKind(toolName),
650
+ "status": "completed", "rawInput": nil, "rawOutput": raw}); err != nil {
651
+ return err
652
+ }
653
+ var content any
654
+ if text := ToolResultToText(m); text != "" {
655
+ content = textContent(text)
656
+ }
657
+ return a.send(sessionID, Update{"sessionUpdate": "tool_call_update", "toolCallId": id, "status": status, "content": content, "rawOutput": raw})
658
+ }
659
+ return nil
660
+ }
661
+
662
+ // DeleteSession removes a session file; unknown ids succeed (session/delete is idempotent).
663
+ func (a *Agent) DeleteSession(req DeleteSessionRequest) (map[string]any, error) {
664
+ stored := a.store.Get(req.SessionID)
665
+ ps := FindPiSession(req.SessionID)
666
+ if stored == nil && ps == nil {
667
+ return map[string]any{}, nil
668
+ }
669
+ file := ""
670
+ if stored != nil {
671
+ file = stored.SessionFile
672
+ } else if ps != nil {
673
+ file = ps.SessionFile
674
+ }
675
+ if file != "" {
676
+ _ = os.Remove(file) // best effort
677
+ }
678
+ a.store.Delete(req.SessionID)
679
+ return map[string]any{}, nil
680
+ }
681
+
682
+ // UnstableSetSessionModel selects a model (session/set_model).
683
+ func (a *Agent) UnstableSetSessionModel(req SetSessionModelRequest) error {
684
+ sess, err := a.restoreSession(req.SessionID, "", nil)
685
+ if err != nil {
686
+ return err
687
+ }
688
+ if err := setSessionModel(sess.Proc(), req.ModelID); err != nil {
689
+ return err
690
+ }
691
+ if _, err := a.emitConfigOptionsUpdate(sess.ID(), sess.Proc()); err != nil {
692
+ return err
693
+ }
694
+ sess.PublishContextUsage()
695
+ return nil
696
+ }
697
+
698
+ // SetSessionMode maps a mode id to a thinking level.
699
+ func (a *Agent) SetSessionMode(req SetSessionModeRequest) (map[string]any, error) {
700
+ sess, err := a.restoreSession(req.SessionID, "", nil)
701
+ if err != nil {
702
+ return nil, err
703
+ }
704
+ mode, ok := req.ModeID.(string)
705
+ if !ok || mode == "" {
706
+ return nil, ErrInvalidParams(nil, "Expected nonempty string modeId")
707
+ }
708
+ if err := sess.Proc().SetThinkingLevel(mode); err != nil {
709
+ return nil, err
710
+ }
711
+ if _, err := a.emitConfigOptionsUpdate(sess.ID(), sess.Proc()); err != nil {
712
+ return nil, err
713
+ }
714
+ return map[string]any{}, nil
715
+ }
716
+
717
+ // SetSessionConfigOption changes the model or the thinking level.
718
+ func (a *Agent) SetSessionConfigOption(req SetSessionConfigOptionRequest) (SetSessionConfigOptionResponse, error) {
719
+ sess, err := a.restoreSession(req.SessionID, "", nil)
720
+ if err != nil {
721
+ return SetSessionConfigOptionResponse{}, err
722
+ }
723
+ value, ok := req.Value.(string)
724
+ if !ok {
725
+ return SetSessionConfigOptionResponse{}, ErrInvalidParams(nil, "Expected string value for config option: "+req.ConfigID)
726
+ }
727
+ modelChanged := false
728
+ switch req.ConfigID {
729
+ case modelConfigID:
730
+ if err := setSessionModel(sess.Proc(), value); err != nil {
731
+ return SetSessionConfigOptionResponse{}, err
732
+ }
733
+ modelChanged = true
734
+ case thoughtLevelConfigID:
735
+ if value == "" {
736
+ return SetSessionConfigOptionResponse{}, ErrInvalidParams(nil, "Expected nonempty thinking level")
737
+ }
738
+ if err := sess.Proc().SetThinkingLevel(value); err != nil {
739
+ return SetSessionConfigOptionResponse{}, err
740
+ }
741
+ default:
742
+ return SetSessionConfigOptionResponse{}, ErrInvalidParams(nil, "Unknown config option: "+req.ConfigID)
743
+ }
744
+ opts, err := a.emitConfigOptionsUpdate(sess.ID(), sess.Proc())
745
+ if err != nil {
746
+ return SetSessionConfigOptionResponse{}, err
747
+ }
748
+ // A different model can mean a different context window: refresh it now.
749
+ if modelChanged {
750
+ sess.PublishContextUsage()
751
+ }
752
+ return SetSessionConfigOptionResponse{ConfigOptions: opts}, nil
753
+ }
754
+
755
+ // FindChangelog looks for a CHANGELOG.md beside the pig installation: next to the executable or
756
+ // one directory up (bin/pig with a CHANGELOG.md at the root). "" when there is none. PiG binaries
757
+ // carry no changelog of their own, so this is only found for installs that ship one.
758
+ func FindChangelog(pigExecutable string) string {
759
+ if pigExecutable == "" {
760
+ pigExecutable = pirpc.Command("")
761
+ }
762
+ path := pigExecutable
763
+ if !strings.ContainsAny(path, `/\`) {
764
+ p, err := exec.LookPath(path)
765
+ if err != nil {
766
+ return ""
767
+ }
768
+ path = p
769
+ }
770
+ resolved, err := filepath.EvalSymlinks(path)
771
+ if err != nil {
772
+ return ""
773
+ }
774
+ dir := filepath.Dir(resolved)
775
+ for _, root := range []string{filepath.Dir(dir), dir} {
776
+ p := filepath.Join(root, "CHANGELOG.md")
777
+ if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() {
778
+ return p
779
+ }
780
+ }
781
+ return ""
782
+ }
783
+
784
+ func timeNowUTC() time.Time { return time.Now().UTC() }