@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,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() }
|