@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,1661 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RequestError,
|
|
3
|
+
type Agent as ACPAgent,
|
|
4
|
+
type AgentSideConnection,
|
|
5
|
+
type AuthenticateRequest,
|
|
6
|
+
type CancelNotification,
|
|
7
|
+
type InitializeRequest,
|
|
8
|
+
type InitializeResponse,
|
|
9
|
+
type ListSessionsRequest,
|
|
10
|
+
type ListSessionsResponse,
|
|
11
|
+
type LoadSessionRequest,
|
|
12
|
+
type LoadSessionResponse,
|
|
13
|
+
type NewSessionRequest,
|
|
14
|
+
type PromptRequest,
|
|
15
|
+
type PromptResponse,
|
|
16
|
+
type SessionConfigOption,
|
|
17
|
+
type SessionInfo,
|
|
18
|
+
type SetSessionConfigOptionRequest,
|
|
19
|
+
type SetSessionConfigOptionResponse,
|
|
20
|
+
type SetSessionModeRequest,
|
|
21
|
+
type SetSessionModeResponse,
|
|
22
|
+
type StopReason,
|
|
23
|
+
type DeleteSessionRequest,
|
|
24
|
+
type DeleteSessionResponse
|
|
25
|
+
} from '@agentclientprotocol/sdk'
|
|
26
|
+
import { getAuthMethods } from './auth.js'
|
|
27
|
+
import { SessionManager, type PiAcpSession } from './session.js'
|
|
28
|
+
import { SessionStore } from './session-store.js'
|
|
29
|
+
import { PiRpcProcess } from '../pi-rpc/process.js'
|
|
30
|
+
import { listPiSessions, findPiSession } from './pi-sessions.js'
|
|
31
|
+
import { normalizePiAssistantText, normalizePiMessageText } from './translate/pi-messages.js'
|
|
32
|
+
import { toolResultToText } from './translate/pi-tools.js'
|
|
33
|
+
import {
|
|
34
|
+
bashCommand,
|
|
35
|
+
bashExitCode,
|
|
36
|
+
bashResultText,
|
|
37
|
+
bashTerminalContent,
|
|
38
|
+
bashTerminalExitMeta,
|
|
39
|
+
bashTerminalInfoMeta,
|
|
40
|
+
bashTerminalOutputMeta,
|
|
41
|
+
isBashTool
|
|
42
|
+
} from './translate/bash.js'
|
|
43
|
+
import { promptToPiMessage } from './translate/prompt.js'
|
|
44
|
+
import { loadSlashCommands, parseCommandArgs, toAvailableCommands } from './slash-commands.js'
|
|
45
|
+
import { getAgentDir, getEnableSkillCommands, getQuietStartup } from './pi-settings.js'
|
|
46
|
+
import { toAvailableCommandsFromPiGetCommands } from './pi-commands.js'
|
|
47
|
+
import { maybeAuthRequiredError } from './auth-required.js'
|
|
48
|
+
import { isAbsolute } from 'node:path'
|
|
49
|
+
import { existsSync, readFileSync, realpathSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
50
|
+
import type { AvailableCommand } from '@agentclientprotocol/sdk'
|
|
51
|
+
import { join, dirname, basename } from 'node:path'
|
|
52
|
+
import { spawnSync } from 'node:child_process'
|
|
53
|
+
|
|
54
|
+
type AdvertisedModel = {
|
|
55
|
+
modelId: string
|
|
56
|
+
name: string
|
|
57
|
+
description?: string | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MODEL_CONFIG_ID = 'model'
|
|
61
|
+
const THOUGHT_LEVEL_CONFIG_ID = 'thought_level'
|
|
62
|
+
|
|
63
|
+
function builtinAvailableCommands(): AvailableCommand[] {
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
name: 'compact',
|
|
67
|
+
description: 'Manually compact the session context',
|
|
68
|
+
input: { hint: 'optional custom instructions' }
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'autocompact',
|
|
72
|
+
description: 'Toggle automatic context compaction',
|
|
73
|
+
input: { hint: 'on|off|toggle' }
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'export',
|
|
77
|
+
description: 'Export session to an HTML file in the session cwd'
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'session',
|
|
81
|
+
description: 'Show session stats (messages, tokens, cost, session file)'
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'name',
|
|
85
|
+
description: 'Set session display name',
|
|
86
|
+
input: { hint: '<name>' }
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'steering',
|
|
90
|
+
description: 'Get/set pi steering message delivery mode (how queued steering messages are delivered)',
|
|
91
|
+
input: { hint: '(no args to show) all | one-at-a-time' }
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'follow-up',
|
|
95
|
+
description: 'Get/set pi follow-up message delivery mode (how queued follow-up messages are delivered)',
|
|
96
|
+
input: { hint: '(no args to show) all | one-at-a-time' }
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'changelog',
|
|
100
|
+
description: 'Show pi changelog'
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mergeCommands(a: AvailableCommand[], b: AvailableCommand[]): AvailableCommand[] {
|
|
106
|
+
// Preserve order, de-dupe by name (first wins).
|
|
107
|
+
const out: AvailableCommand[] = []
|
|
108
|
+
const seen = new Set<string>()
|
|
109
|
+
|
|
110
|
+
for (const c of [...a, ...b]) {
|
|
111
|
+
if (seen.has(c.name)) continue
|
|
112
|
+
seen.add(c.name)
|
|
113
|
+
out.push(c)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
import { fileURLToPath } from 'node:url'
|
|
119
|
+
|
|
120
|
+
const pkg = readNearestPackageJson(import.meta.url)
|
|
121
|
+
|
|
122
|
+
export class PiAcpAgent implements ACPAgent {
|
|
123
|
+
private readonly conn: AgentSideConnection
|
|
124
|
+
private readonly sessions = new SessionManager()
|
|
125
|
+
private readonly store = new SessionStore()
|
|
126
|
+
private readonly restoringSessions = new Map<string, Promise<PiAcpSession>>()
|
|
127
|
+
|
|
128
|
+
dispose(): void {
|
|
129
|
+
this.sessions.disposeAll()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Remember recent session cwd and use it as the default filter.
|
|
133
|
+
private lastSessionCwd: string | null = null
|
|
134
|
+
|
|
135
|
+
constructor(conn: AgentSideConnection, _config?: unknown) {
|
|
136
|
+
this.conn = conn
|
|
137
|
+
void _config
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private cleanupFailedNewSession(sessionId: string, state?: any | null): void {
|
|
141
|
+
this.sessions.close(sessionId)
|
|
142
|
+
|
|
143
|
+
const sessionFile =
|
|
144
|
+
typeof state?.sessionFile === 'string' && state.sessionFile.trim()
|
|
145
|
+
? state.sessionFile
|
|
146
|
+
: this.store.get(sessionId)?.sessionFile
|
|
147
|
+
|
|
148
|
+
if (typeof sessionFile === 'string' && sessionFile.trim()) {
|
|
149
|
+
try {
|
|
150
|
+
if (existsSync(sessionFile)) unlinkSync(sessionFile)
|
|
151
|
+
} catch {
|
|
152
|
+
// ignore cleanup failures; the auth/internal error is the primary result
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.store.delete(sessionId)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private findStoredSession(sessionId: string): { cwd: string; sessionFile: string } | null {
|
|
160
|
+
const stored = this.store.get(sessionId)
|
|
161
|
+
if (stored?.cwd && stored?.sessionFile) {
|
|
162
|
+
return { cwd: stored.cwd, sessionFile: stored.sessionFile }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const piSession = findPiSession(sessionId)
|
|
166
|
+
if (!piSession) return null
|
|
167
|
+
|
|
168
|
+
this.store.upsert({
|
|
169
|
+
sessionId,
|
|
170
|
+
cwd: piSession.cwd,
|
|
171
|
+
sessionFile: piSession.sessionFile
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
cwd: piSession.cwd,
|
|
176
|
+
sessionFile: piSession.sessionFile
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async restoreSession(
|
|
181
|
+
sessionId: string,
|
|
182
|
+
opts?: { cwd?: string; mcpServers?: LoadSessionRequest['mcpServers'] }
|
|
183
|
+
): Promise<PiAcpSession> {
|
|
184
|
+
const existing = this.sessions.maybeGet(sessionId)
|
|
185
|
+
if (existing) return existing
|
|
186
|
+
|
|
187
|
+
const inFlight = this.restoringSessions.get(sessionId)
|
|
188
|
+
if (inFlight) return inFlight
|
|
189
|
+
|
|
190
|
+
const restorePromise = (async () => {
|
|
191
|
+
const stored = this.findStoredSession(sessionId)
|
|
192
|
+
if (!stored) {
|
|
193
|
+
throw RequestError.invalidParams(`Unknown sessionId: ${sessionId}`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cwd = opts?.cwd ?? stored.cwd
|
|
197
|
+
|
|
198
|
+
let proc: PiRpcProcess
|
|
199
|
+
try {
|
|
200
|
+
proc = await PiRpcProcess.spawn({
|
|
201
|
+
cwd,
|
|
202
|
+
sessionPath: stored.sessionFile,
|
|
203
|
+
piCommand: process.env.PI_ACP_PI_COMMAND
|
|
204
|
+
})
|
|
205
|
+
} catch (e: any) {
|
|
206
|
+
if (e?.name === 'PiRpcSpawnError') {
|
|
207
|
+
throw RequestError.internalError({ code: e?.code }, String(e?.message ?? e))
|
|
208
|
+
}
|
|
209
|
+
throw e
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const fileCommands = loadSlashCommands(cwd)
|
|
213
|
+
const session = this.sessions.getOrCreate(sessionId, {
|
|
214
|
+
cwd,
|
|
215
|
+
mcpServers: opts?.mcpServers ?? [],
|
|
216
|
+
conn: this.conn,
|
|
217
|
+
proc,
|
|
218
|
+
fileCommands
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
this.lastSessionCwd = cwd
|
|
222
|
+
this.store.upsert({ sessionId, cwd, sessionFile: stored.sessionFile })
|
|
223
|
+
|
|
224
|
+
return session
|
|
225
|
+
})()
|
|
226
|
+
|
|
227
|
+
this.restoringSessions.set(sessionId, restorePromise)
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
return await restorePromise
|
|
231
|
+
} finally {
|
|
232
|
+
this.restoringSessions.delete(sessionId)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
|
237
|
+
// We currently only support ACP protocol version 1.
|
|
238
|
+
const supportedVersion = 1
|
|
239
|
+
const requested = params.protocolVersion
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
protocolVersion: requested === supportedVersion ? requested : supportedVersion,
|
|
243
|
+
agentInfo: {
|
|
244
|
+
name: pkg.name ?? 'pi-acp',
|
|
245
|
+
title: 'pi ACP adapter',
|
|
246
|
+
version: pkg.version ?? '0.0.0'
|
|
247
|
+
},
|
|
248
|
+
// Zed currently uses ClientCapabilities._meta["terminal-auth"] to decide whether to show
|
|
249
|
+
// the "Authenticate" banner/button. If not supported, we still return the method for the registry.
|
|
250
|
+
authMethods: getAuthMethods({
|
|
251
|
+
supportsTerminalAuthMeta: (params as any)?.clientCapabilities?._meta?.['terminal-auth'] === true
|
|
252
|
+
}),
|
|
253
|
+
agentCapabilities: {
|
|
254
|
+
loadSession: true,
|
|
255
|
+
mcpCapabilities: { http: false, sse: false },
|
|
256
|
+
promptCapabilities: {
|
|
257
|
+
image: true,
|
|
258
|
+
audio: false,
|
|
259
|
+
embeddedContext: process.env.PI_ACP_ENABLE_EMBEDDED_CONTEXT === 'true'
|
|
260
|
+
},
|
|
261
|
+
sessionCapabilities: {
|
|
262
|
+
// **UNSTABLE** ACP capability used by Zed's codex-acp adapter.
|
|
263
|
+
// Enables a native session picker in clients that support it.
|
|
264
|
+
list: {},
|
|
265
|
+
delete: {}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async newSession(params: NewSessionRequest) {
|
|
272
|
+
if (!isAbsolute(params.cwd)) {
|
|
273
|
+
throw RequestError.invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.lastSessionCwd = params.cwd
|
|
277
|
+
|
|
278
|
+
const fileCommands = loadSlashCommands(params.cwd)
|
|
279
|
+
const enableSkillCommands = getEnableSkillCommands(params.cwd)
|
|
280
|
+
|
|
281
|
+
// Pi doesn't support mcpServers, but we accept and store.
|
|
282
|
+
const session = await this.sessions.create({
|
|
283
|
+
cwd: params.cwd,
|
|
284
|
+
mcpServers: params.mcpServers,
|
|
285
|
+
conn: this.conn,
|
|
286
|
+
fileCommands,
|
|
287
|
+
piCommand: process.env.PI_ACP_PI_COMMAND
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// Fetch state + models once (parallel) to reduce startup latency.
|
|
291
|
+
let state: any = null
|
|
292
|
+
let availableModels: any = null
|
|
293
|
+
let stateErr: unknown = null
|
|
294
|
+
let availableModelsErr: unknown = null
|
|
295
|
+
|
|
296
|
+
await Promise.all([
|
|
297
|
+
session.proc
|
|
298
|
+
.getState()
|
|
299
|
+
.then(s => {
|
|
300
|
+
state = s as any
|
|
301
|
+
})
|
|
302
|
+
.catch(err => {
|
|
303
|
+
stateErr = err
|
|
304
|
+
state = null
|
|
305
|
+
}),
|
|
306
|
+
session.proc
|
|
307
|
+
.getAvailableModels()
|
|
308
|
+
.then(m => {
|
|
309
|
+
availableModels = m as any
|
|
310
|
+
})
|
|
311
|
+
.catch(err => {
|
|
312
|
+
availableModelsErr = err
|
|
313
|
+
availableModels = null
|
|
314
|
+
})
|
|
315
|
+
])
|
|
316
|
+
|
|
317
|
+
const availableModelsAuthErr = maybeAuthRequiredError(availableModelsErr)
|
|
318
|
+
|
|
319
|
+
if (availableModelsAuthErr) {
|
|
320
|
+
this.cleanupFailedNewSession(session.sessionId, state)
|
|
321
|
+
throw availableModelsAuthErr
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (availableModelsErr) {
|
|
325
|
+
this.cleanupFailedNewSession(session.sessionId, state)
|
|
326
|
+
throw RequestError.internalError({}, String((availableModelsErr as Error)?.message ?? availableModelsErr))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// If pi has no models available after spawning, it's effectively unauthenticated.
|
|
330
|
+
const rawModelsCount = Array.isArray(availableModels?.models) ? availableModels.models.length : 0
|
|
331
|
+
|
|
332
|
+
if (rawModelsCount === 0) {
|
|
333
|
+
this.cleanupFailedNewSession(session.sessionId, state)
|
|
334
|
+
throw RequestError.authRequired(
|
|
335
|
+
{ authMethods: getAuthMethods() },
|
|
336
|
+
'Configure an API key or log in with an OAuth provider.'
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (stateErr && maybeAuthRequiredError(stateErr)) {
|
|
341
|
+
this.cleanupFailedNewSession(session.sessionId, state)
|
|
342
|
+
throw RequestError.authRequired(
|
|
343
|
+
{ authMethods: getAuthMethods() },
|
|
344
|
+
'Configure an API key or log in with an OAuth provider.'
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let configuration: Awaited<ReturnType<typeof getSessionConfiguration>>
|
|
349
|
+
try {
|
|
350
|
+
if (stateErr) throw stateErr
|
|
351
|
+
configuration = await getSessionConfiguration(session.proc, { state, availableModels })
|
|
352
|
+
} catch (err) {
|
|
353
|
+
this.cleanupFailedNewSession(session.sessionId, state)
|
|
354
|
+
throw maybeAuthRequiredError(err) ?? RequestError.internalError({}, String((err as Error)?.message ?? err))
|
|
355
|
+
}
|
|
356
|
+
const { configOptions, models, modes } = configuration
|
|
357
|
+
|
|
358
|
+
const quietStartup = getQuietStartup(params.cwd)
|
|
359
|
+
const updateNotice = buildUpdateNotice()
|
|
360
|
+
|
|
361
|
+
// If quietStartup is enabled, suppress the full "startup info" prelude, but still surface
|
|
362
|
+
// the "New version available" notice (if any) since it's high-signal and actionable.
|
|
363
|
+
const preludeText = quietStartup
|
|
364
|
+
? updateNotice
|
|
365
|
+
? updateNotice + '\n'
|
|
366
|
+
: ''
|
|
367
|
+
: buildStartupInfo({
|
|
368
|
+
cwd: params.cwd,
|
|
369
|
+
fileCommands,
|
|
370
|
+
updateNotice
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
if (preludeText)
|
|
374
|
+
session.setStartupInfo(preludeText)
|
|
375
|
+
|
|
376
|
+
// Policy: within a single ACP connection (one client window), keep only one live pi subprocess.
|
|
377
|
+
// This avoids leaking subprocesses when clients start new sessions but don't explicitly close old ones.
|
|
378
|
+
// It does NOT affect other client windows because they run in separate agent processes.
|
|
379
|
+
//
|
|
380
|
+
// (Tests sometimes stub out `this.sessions`, so guard the call.)
|
|
381
|
+
;(this.sessions as any).closeAllExcept?.(session.sessionId)
|
|
382
|
+
|
|
383
|
+
const response = {
|
|
384
|
+
sessionId: session.sessionId,
|
|
385
|
+
configOptions,
|
|
386
|
+
models,
|
|
387
|
+
modes,
|
|
388
|
+
_meta: {
|
|
389
|
+
piAcp: {
|
|
390
|
+
startupInfo: preludeText || null
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Try to send it immediately after session/new returns; if the client ignores it,
|
|
396
|
+
// it will still be emitted as the first chunk of the first prompt.
|
|
397
|
+
if (preludeText) setTimeout(() => session.sendStartupInfoIfPending(), 0)
|
|
398
|
+
|
|
399
|
+
// Advertise slash commands (ACP: available_commands_update)
|
|
400
|
+
// Important: some clients (e.g. Zed) will ignore notifications for an unknown sessionId.
|
|
401
|
+
// So we must send this *after* the session/new response has been delivered.
|
|
402
|
+
setTimeout(() => {
|
|
403
|
+
void (async () => {
|
|
404
|
+
// Publish real context usage now that the client knows the sessionId (clients ignore
|
|
405
|
+
// notifications for unknown sessions), so the window size is correct before the first prompt.
|
|
406
|
+
await session.publishContextUsage()
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const pi = (await session.proc.getCommands()) as any
|
|
410
|
+
const { commands } = toAvailableCommandsFromPiGetCommands(pi, {
|
|
411
|
+
enableSkillCommands,
|
|
412
|
+
includeExtensionCommands: false
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
await this.conn.sessionUpdate({
|
|
416
|
+
sessionId: session.sessionId,
|
|
417
|
+
update: {
|
|
418
|
+
sessionUpdate: 'available_commands_update',
|
|
419
|
+
availableCommands: mergeCommands(commands, builtinAvailableCommands())
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
return
|
|
423
|
+
} catch {
|
|
424
|
+
// Fall back to file-based prompt templates (legacy behavior).
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
await this.conn.sessionUpdate({
|
|
428
|
+
sessionId: session.sessionId,
|
|
429
|
+
update: {
|
|
430
|
+
sessionUpdate: 'available_commands_update',
|
|
431
|
+
availableCommands: mergeCommands(toAvailableCommands(fileCommands), builtinAvailableCommands())
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
})()
|
|
435
|
+
}, 0)
|
|
436
|
+
|
|
437
|
+
return response
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async authenticate(_params: AuthenticateRequest) {
|
|
441
|
+
// Terminal Auth is handled out-of-band by re-launching the binary with `--terminal-login`.
|
|
442
|
+
// If the client calls `authenticate` anyway, we can no-op successfully.
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
|
447
|
+
const session = await this.restoreSession(params.sessionId)
|
|
448
|
+
|
|
449
|
+
const { message, images } = promptToPiMessage(params.prompt)
|
|
450
|
+
|
|
451
|
+
// Built-in ACP slash command handling (headless-friendly subset).
|
|
452
|
+
// Note: file-based slash commands are expanded inside session.prompt().
|
|
453
|
+
if (images.length === 0 && message.trimStart().startsWith('/')) {
|
|
454
|
+
const trimmed = message.trim()
|
|
455
|
+
const space = trimmed.indexOf(' ')
|
|
456
|
+
const cmd = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space)
|
|
457
|
+
const argsString = space === -1 ? '' : trimmed.slice(space + 1)
|
|
458
|
+
const args = parseCommandArgs(argsString)
|
|
459
|
+
|
|
460
|
+
if (cmd === 'compact') {
|
|
461
|
+
const customInstructions = args.join(' ').trim() || undefined
|
|
462
|
+
const res = await session.proc.compact(customInstructions)
|
|
463
|
+
|
|
464
|
+
const r: any = res && typeof res === 'object' ? (res as any) : null
|
|
465
|
+
const tokensBefore = typeof r?.tokensBefore === 'number' ? r.tokensBefore : null
|
|
466
|
+
const summary = typeof r?.summary === 'string' ? r.summary : null
|
|
467
|
+
|
|
468
|
+
const headerLines = [
|
|
469
|
+
`Compaction completed.${customInstructions ? ' (custom instructions applied)' : ''}`,
|
|
470
|
+
tokensBefore !== null ? `Tokens before: ${tokensBefore}` : null
|
|
471
|
+
].filter(Boolean)
|
|
472
|
+
|
|
473
|
+
const text = headerLines.join('\n') + (summary ? `\n\n${summary}` : '')
|
|
474
|
+
|
|
475
|
+
await this.conn.sessionUpdate({
|
|
476
|
+
sessionId: session.sessionId,
|
|
477
|
+
update: {
|
|
478
|
+
sessionUpdate: 'agent_message_chunk',
|
|
479
|
+
content: { type: 'text', text }
|
|
480
|
+
}
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
return { stopReason: 'end_turn' }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (cmd === 'session') {
|
|
487
|
+
const stats = await session.proc.getSessionStats()
|
|
488
|
+
|
|
489
|
+
const lines: string[] = []
|
|
490
|
+
if (stats?.sessionId) lines.push(`Session: ${stats.sessionId}`)
|
|
491
|
+
if (stats?.sessionFile) lines.push(`Session file: ${stats.sessionFile}`)
|
|
492
|
+
if (typeof stats?.totalMessages === 'number') lines.push(`Messages: ${stats.totalMessages}`)
|
|
493
|
+
|
|
494
|
+
if (typeof stats?.cost === 'number') lines.push(`Cost: ${stats.cost}`)
|
|
495
|
+
|
|
496
|
+
const t = stats?.tokens
|
|
497
|
+
if (t && typeof t === 'object') {
|
|
498
|
+
const parts: string[] = []
|
|
499
|
+
if (typeof t.input === 'number') parts.push(`in ${t.input}`)
|
|
500
|
+
if (typeof t.output === 'number') parts.push(`out ${t.output}`)
|
|
501
|
+
if (typeof t.cacheRead === 'number') parts.push(`cache read ${t.cacheRead}`)
|
|
502
|
+
if (typeof t.cacheWrite === 'number') parts.push(`cache write ${t.cacheWrite}`)
|
|
503
|
+
if (typeof t.total === 'number') parts.push(`total ${t.total}`)
|
|
504
|
+
if (parts.length) lines.push(`Tokens: ${parts.join(', ')}`)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Fallback if stats shape changes.
|
|
508
|
+
const text = lines.length ? lines.join('\n') : `Session stats:\n${JSON.stringify(stats, null, 2)}`
|
|
509
|
+
|
|
510
|
+
await this.conn.sessionUpdate({
|
|
511
|
+
sessionId: session.sessionId,
|
|
512
|
+
update: {
|
|
513
|
+
sessionUpdate: 'agent_message_chunk',
|
|
514
|
+
content: { type: 'text', text }
|
|
515
|
+
}
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
return { stopReason: 'end_turn' }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (cmd === 'name') {
|
|
522
|
+
const name = args.join(' ').trim()
|
|
523
|
+
if (!name) {
|
|
524
|
+
await this.conn.sessionUpdate({
|
|
525
|
+
sessionId: session.sessionId,
|
|
526
|
+
update: {
|
|
527
|
+
sessionUpdate: 'agent_message_chunk',
|
|
528
|
+
content: { type: 'text', text: 'Usage: /name <name>' }
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
return { stopReason: 'end_turn' }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
await session.proc.setSessionName(name)
|
|
536
|
+
} catch (e: any) {
|
|
537
|
+
const msg = String(e?.message ?? e)
|
|
538
|
+
const hint = /set_session_name/i.test(msg)
|
|
539
|
+
? ' This requires a newer pi version that supports `set_session_name` in RPC mode.'
|
|
540
|
+
: ''
|
|
541
|
+
|
|
542
|
+
await this.conn.sessionUpdate({
|
|
543
|
+
sessionId: session.sessionId,
|
|
544
|
+
update: {
|
|
545
|
+
sessionUpdate: 'agent_message_chunk',
|
|
546
|
+
content: { type: 'text', text: `Failed to set session name: ${msg}${hint}` }
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
return { stopReason: 'end_turn' }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
await this.conn.sessionUpdate({
|
|
553
|
+
sessionId: session.sessionId,
|
|
554
|
+
update: {
|
|
555
|
+
sessionUpdate: 'session_info_update',
|
|
556
|
+
title: name,
|
|
557
|
+
updatedAt: new Date().toISOString()
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
await this.conn.sessionUpdate({
|
|
562
|
+
sessionId: session.sessionId,
|
|
563
|
+
update: {
|
|
564
|
+
sessionUpdate: 'agent_message_chunk',
|
|
565
|
+
content: { type: 'text', text: `Session name set: ${name}` }
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
return { stopReason: 'end_turn' }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (cmd === 'steering') {
|
|
573
|
+
const modeRaw = String(args[0] ?? '').toLowerCase()
|
|
574
|
+
const state = (await session.proc.getState()) as any
|
|
575
|
+
const current = String(state?.steeringMode ?? '')
|
|
576
|
+
|
|
577
|
+
// If no arg, just report current.
|
|
578
|
+
if (!modeRaw) {
|
|
579
|
+
await this.conn.sessionUpdate({
|
|
580
|
+
sessionId: session.sessionId,
|
|
581
|
+
update: {
|
|
582
|
+
sessionUpdate: 'agent_message_chunk',
|
|
583
|
+
content: {
|
|
584
|
+
type: 'text',
|
|
585
|
+
text: `Steering mode: ${current || 'unknown'}`
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
return { stopReason: 'end_turn' }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (modeRaw !== 'all' && modeRaw !== 'one-at-a-time') {
|
|
593
|
+
await this.conn.sessionUpdate({
|
|
594
|
+
sessionId: session.sessionId,
|
|
595
|
+
update: {
|
|
596
|
+
sessionUpdate: 'agent_message_chunk',
|
|
597
|
+
content: {
|
|
598
|
+
type: 'text',
|
|
599
|
+
text: 'Usage: /steering all | /steering one-at-a-time'
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
return { stopReason: 'end_turn' }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
await session.proc.setSteeringMode(modeRaw as 'all' | 'one-at-a-time')
|
|
607
|
+
|
|
608
|
+
await this.conn.sessionUpdate({
|
|
609
|
+
sessionId: session.sessionId,
|
|
610
|
+
update: {
|
|
611
|
+
sessionUpdate: 'agent_message_chunk',
|
|
612
|
+
content: { type: 'text', text: `Steering mode set to: ${modeRaw}` }
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
return { stopReason: 'end_turn' }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (cmd === 'follow-up') {
|
|
620
|
+
const modeRaw = String(args[0] ?? '').toLowerCase()
|
|
621
|
+
const state = (await session.proc.getState()) as any
|
|
622
|
+
const current = String(state?.followUpMode ?? '')
|
|
623
|
+
|
|
624
|
+
// If no arg, just report current.
|
|
625
|
+
if (!modeRaw) {
|
|
626
|
+
await this.conn.sessionUpdate({
|
|
627
|
+
sessionId: session.sessionId,
|
|
628
|
+
update: {
|
|
629
|
+
sessionUpdate: 'agent_message_chunk',
|
|
630
|
+
content: {
|
|
631
|
+
type: 'text',
|
|
632
|
+
text: `Follow-up mode: ${current || 'unknown'}`
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
return { stopReason: 'end_turn' }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (modeRaw !== 'all' && modeRaw !== 'one-at-a-time') {
|
|
640
|
+
await this.conn.sessionUpdate({
|
|
641
|
+
sessionId: session.sessionId,
|
|
642
|
+
update: {
|
|
643
|
+
sessionUpdate: 'agent_message_chunk',
|
|
644
|
+
content: {
|
|
645
|
+
type: 'text',
|
|
646
|
+
text: 'Usage: /follow-up all | /follow-up one-at-a-time'
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
return { stopReason: 'end_turn' }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
await session.proc.setFollowUpMode(modeRaw as 'all' | 'one-at-a-time')
|
|
654
|
+
|
|
655
|
+
await this.conn.sessionUpdate({
|
|
656
|
+
sessionId: session.sessionId,
|
|
657
|
+
update: {
|
|
658
|
+
sessionUpdate: 'agent_message_chunk',
|
|
659
|
+
content: { type: 'text', text: `Follow-up mode set to: ${modeRaw}` }
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
return { stopReason: 'end_turn' }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (cmd === 'changelog') {
|
|
667
|
+
// Read pi's installed CHANGELOG.md. Adapter-side, no model call.
|
|
668
|
+
const findChangelog = (): string | null => {
|
|
669
|
+
// 1) Locate the installed pi package by resolving the `pi` executable.
|
|
670
|
+
// On Node installs, `pi` typically resolves to .../@earendil-works/pi-coding-agent/dist/cli.js
|
|
671
|
+
try {
|
|
672
|
+
const whichCmd = process.platform === 'win32' ? 'where' : 'which'
|
|
673
|
+
const which = spawnSync(whichCmd, ['pi'], { encoding: 'utf-8' })
|
|
674
|
+
const piPath = String(which.stdout ?? '')
|
|
675
|
+
.split(/\r?\n/)[0]
|
|
676
|
+
?.trim()
|
|
677
|
+
|
|
678
|
+
if (piPath) {
|
|
679
|
+
const resolved = realpathSync(piPath)
|
|
680
|
+
const pkgRoot = dirname(dirname(resolved))
|
|
681
|
+
const p = join(pkgRoot, 'CHANGELOG.md')
|
|
682
|
+
if (existsSync(p)) return p
|
|
683
|
+
}
|
|
684
|
+
} catch {
|
|
685
|
+
// ignore
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 2) Fallback: ask npm where global modules live.
|
|
689
|
+
try {
|
|
690
|
+
const npmRoot = spawnSync('npm', ['root', '-g'], { encoding: 'utf-8' })
|
|
691
|
+
const root = String(npmRoot.stdout ?? '').trim()
|
|
692
|
+
if (root) {
|
|
693
|
+
const p = join(root, '@earendil-works', 'pi-coding-agent', 'CHANGELOG.md')
|
|
694
|
+
if (existsSync(p)) return p
|
|
695
|
+
}
|
|
696
|
+
} catch {
|
|
697
|
+
// ignore
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return null
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const changelogPath = findChangelog()
|
|
704
|
+
if (!changelogPath) {
|
|
705
|
+
await this.conn.sessionUpdate({
|
|
706
|
+
sessionId: session.sessionId,
|
|
707
|
+
update: {
|
|
708
|
+
sessionUpdate: 'agent_message_chunk',
|
|
709
|
+
content: { type: 'text', text: "Changelog not found (couldn't locate pi installation)." }
|
|
710
|
+
}
|
|
711
|
+
})
|
|
712
|
+
return { stopReason: 'end_turn' }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
let text = ''
|
|
716
|
+
try {
|
|
717
|
+
text = readFileSync(changelogPath, 'utf-8')
|
|
718
|
+
} catch (e: any) {
|
|
719
|
+
await this.conn.sessionUpdate({
|
|
720
|
+
sessionId: session.sessionId,
|
|
721
|
+
update: {
|
|
722
|
+
sessionUpdate: 'agent_message_chunk',
|
|
723
|
+
content: { type: 'text', text: `Failed to read changelog: ${String(e?.message ?? e)}` }
|
|
724
|
+
}
|
|
725
|
+
})
|
|
726
|
+
return { stopReason: 'end_turn' }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Keep it reasonably sized in chat.
|
|
730
|
+
const maxChars = 20_000
|
|
731
|
+
if (text.length > maxChars) text = text.slice(0, maxChars) + '\n\n...(truncated)...'
|
|
732
|
+
|
|
733
|
+
await this.conn.sessionUpdate({
|
|
734
|
+
sessionId: session.sessionId,
|
|
735
|
+
update: {
|
|
736
|
+
sessionUpdate: 'agent_message_chunk',
|
|
737
|
+
content: { type: 'text', text }
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
return { stopReason: 'end_turn' }
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (cmd === 'export') {
|
|
745
|
+
// For now we always export into the session cwd and do not accept a user-provided path.
|
|
746
|
+
// IMPORTANT: pi's export_html reads the session JSONL file. If it doesn't exist yet
|
|
747
|
+
// (no messages) or is empty, pi throws and RPC mode emits an uncorrelated parse error
|
|
748
|
+
// (no id), which would otherwise hang our request. So we guard here.
|
|
749
|
+
const state = (await session.proc.getState()) as any
|
|
750
|
+
const sessionFile = typeof state?.sessionFile === 'string' ? state.sessionFile : null
|
|
751
|
+
const messageCount = typeof state?.messageCount === 'number' ? state.messageCount : 0
|
|
752
|
+
|
|
753
|
+
if (!sessionFile || messageCount === 0 || !existsSync(sessionFile)) {
|
|
754
|
+
await this.conn.sessionUpdate({
|
|
755
|
+
sessionId: session.sessionId,
|
|
756
|
+
update: {
|
|
757
|
+
sessionUpdate: 'agent_message_chunk',
|
|
758
|
+
content: {
|
|
759
|
+
type: 'text',
|
|
760
|
+
text: 'Nothing to export yet (no session messages). Send a prompt first.'
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
})
|
|
764
|
+
return { stopReason: 'end_turn' }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
const raw = readFileSync(sessionFile, 'utf-8')
|
|
769
|
+
if (raw.trim().length === 0) {
|
|
770
|
+
await this.conn.sessionUpdate({
|
|
771
|
+
sessionId: session.sessionId,
|
|
772
|
+
update: {
|
|
773
|
+
sessionUpdate: 'agent_message_chunk',
|
|
774
|
+
content: {
|
|
775
|
+
type: 'text',
|
|
776
|
+
text: 'Nothing to export yet (empty session file). Send a prompt first.'
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
})
|
|
780
|
+
return { stopReason: 'end_turn' }
|
|
781
|
+
}
|
|
782
|
+
} catch {
|
|
783
|
+
await this.conn.sessionUpdate({
|
|
784
|
+
sessionId: session.sessionId,
|
|
785
|
+
update: {
|
|
786
|
+
sessionUpdate: 'agent_message_chunk',
|
|
787
|
+
content: {
|
|
788
|
+
type: 'text',
|
|
789
|
+
text: "Couldn't read session file for export. Try sending a prompt first."
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
return { stopReason: 'end_turn' }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const safeSessionId = session.sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
797
|
+
const outputPath = join(session.cwd, `pi-session-${safeSessionId}.html`)
|
|
798
|
+
|
|
799
|
+
let resultPath = ''
|
|
800
|
+
try {
|
|
801
|
+
const result = await session.proc.exportHtml(outputPath)
|
|
802
|
+
resultPath = result.path
|
|
803
|
+
} catch (e: any) {
|
|
804
|
+
await this.conn.sessionUpdate({
|
|
805
|
+
sessionId: session.sessionId,
|
|
806
|
+
update: {
|
|
807
|
+
sessionUpdate: 'agent_message_chunk',
|
|
808
|
+
content: {
|
|
809
|
+
type: 'text',
|
|
810
|
+
text: `Export failed: ${String(e?.message ?? e)}`
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
})
|
|
814
|
+
return { stopReason: 'end_turn' }
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (!resultPath) {
|
|
818
|
+
await this.conn.sessionUpdate({
|
|
819
|
+
sessionId: session.sessionId,
|
|
820
|
+
update: {
|
|
821
|
+
sessionUpdate: 'agent_message_chunk',
|
|
822
|
+
content: {
|
|
823
|
+
type: 'text',
|
|
824
|
+
text: 'Export failed: no output path returned by pi.'
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
return { stopReason: 'end_turn' }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const uri = `file://${resultPath}`
|
|
832
|
+
|
|
833
|
+
// Emit a short prefix + a resource link. Many clients concatenate chunks into a single
|
|
834
|
+
// assistant message, so this avoids the "link + duplicate plain text" look.
|
|
835
|
+
await this.conn.sessionUpdate({
|
|
836
|
+
sessionId: session.sessionId,
|
|
837
|
+
update: {
|
|
838
|
+
sessionUpdate: 'agent_message_chunk',
|
|
839
|
+
content: {
|
|
840
|
+
type: 'text',
|
|
841
|
+
text: 'Session exported: '
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
await this.conn.sessionUpdate({
|
|
847
|
+
sessionId: session.sessionId,
|
|
848
|
+
update: {
|
|
849
|
+
sessionUpdate: 'agent_message_chunk',
|
|
850
|
+
content: {
|
|
851
|
+
type: 'resource_link',
|
|
852
|
+
name: `pi-session-${safeSessionId}.html`,
|
|
853
|
+
uri,
|
|
854
|
+
mimeType: 'text/html',
|
|
855
|
+
title: 'Session exported'
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
return { stopReason: 'end_turn' }
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (cmd === 'autocompact') {
|
|
864
|
+
const mode = (args[0] ?? 'toggle').toLowerCase()
|
|
865
|
+
let enabled: boolean | null = null
|
|
866
|
+
if (mode === 'on' || mode === 'true' || mode === 'enable' || mode === 'enabled') enabled = true
|
|
867
|
+
else if (mode === 'off' || mode === 'false' || mode === 'disable' || mode === 'disabled') enabled = false
|
|
868
|
+
|
|
869
|
+
if (enabled === null) {
|
|
870
|
+
// toggle: read current state and invert.
|
|
871
|
+
const state = (await session.proc.getState()) as any
|
|
872
|
+
const current = Boolean(state?.autoCompactionEnabled)
|
|
873
|
+
enabled = !current
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
await session.proc.setAutoCompaction(enabled)
|
|
877
|
+
|
|
878
|
+
await this.conn.sessionUpdate({
|
|
879
|
+
sessionId: session.sessionId,
|
|
880
|
+
update: {
|
|
881
|
+
sessionUpdate: 'agent_message_chunk',
|
|
882
|
+
content: {
|
|
883
|
+
type: 'text',
|
|
884
|
+
text: `Auto-compaction ${enabled ? 'enabled' : 'disabled'}.`
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
return { stopReason: 'end_turn' }
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const result = await session.prompt(message, images)
|
|
894
|
+
|
|
895
|
+
// ACP StopReason does not include "error"; if pi fails we map to end_turn for now,
|
|
896
|
+
// unless we know this was a cancellation.
|
|
897
|
+
const stopReason: StopReason =
|
|
898
|
+
result === 'error' ? (session.wasCancelRequested() ? 'cancelled' : 'end_turn') : result
|
|
899
|
+
|
|
900
|
+
return { stopReason }
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async cancel(params: CancelNotification): Promise<void> {
|
|
904
|
+
const session = this.sessions.maybeGet(params.sessionId)
|
|
905
|
+
if (!session) return
|
|
906
|
+
await session.cancel()
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
|
910
|
+
// ACP: filter by cwd if provided.
|
|
911
|
+
// Zed currently sends `{}` (no cwd), so we default to the last session cwd to
|
|
912
|
+
// emulate pi's `/resume` picker (project-scoped).
|
|
913
|
+
const all = listPiSessions()
|
|
914
|
+
|
|
915
|
+
const effectiveCwd = (params as any).cwd ?? this.lastSessionCwd
|
|
916
|
+
const filtered = effectiveCwd ? all.filter(s => s.cwd === effectiveCwd) : all
|
|
917
|
+
|
|
918
|
+
// Cursor-based pagination (opaque cursor). For MVP, we use a simple numeric offset.
|
|
919
|
+
// If cursor is invalid, treat as 0.
|
|
920
|
+
const offset = params.cursor ? Number.parseInt(params.cursor, 10) : 0
|
|
921
|
+
const start = Number.isFinite(offset) && offset > 0 ? offset : 0
|
|
922
|
+
|
|
923
|
+
const PAGE_SIZE = 50
|
|
924
|
+
const page = filtered.slice(start, start + PAGE_SIZE)
|
|
925
|
+
|
|
926
|
+
const sessions: SessionInfo[] = page.map(s => ({
|
|
927
|
+
sessionId: s.sessionId,
|
|
928
|
+
cwd: s.cwd,
|
|
929
|
+
title: s.title,
|
|
930
|
+
updatedAt: s.updatedAt
|
|
931
|
+
}))
|
|
932
|
+
|
|
933
|
+
const nextCursor = start + PAGE_SIZE < filtered.length ? String(start + PAGE_SIZE) : null
|
|
934
|
+
|
|
935
|
+
return { sessions, nextCursor, _meta: {} }
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
|
939
|
+
if (!isAbsolute(params.cwd)) {
|
|
940
|
+
throw RequestError.invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// If the client is re-loading a session that is already active, tear down the existing
|
|
944
|
+
// pi subprocess so we can start fresh and re-advertise commands reliably.
|
|
945
|
+
// (Some clients may call session/load when restoring from history.)
|
|
946
|
+
this.sessions.close(params.sessionId)
|
|
947
|
+
|
|
948
|
+
this.lastSessionCwd = params.cwd
|
|
949
|
+
|
|
950
|
+
const stored = this.findStoredSession(params.sessionId)
|
|
951
|
+
if (!stored) {
|
|
952
|
+
throw RequestError.invalidParams(`Unknown sessionId: ${params.sessionId}`)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const enableSkillCommands = getEnableSkillCommands(params.cwd)
|
|
956
|
+
const session = await this.restoreSession(params.sessionId, {
|
|
957
|
+
cwd: params.cwd,
|
|
958
|
+
mcpServers: params.mcpServers
|
|
959
|
+
})
|
|
960
|
+
const proc = session.proc
|
|
961
|
+
let configuration: Awaited<ReturnType<typeof getSessionConfiguration>>
|
|
962
|
+
try {
|
|
963
|
+
configuration = await getSessionConfiguration(proc)
|
|
964
|
+
} catch (err) {
|
|
965
|
+
this.sessions.close(session.sessionId)
|
|
966
|
+
throw err
|
|
967
|
+
}
|
|
968
|
+
const { configOptions, models, modes } = configuration
|
|
969
|
+
const fileCommands = loadSlashCommands(params.cwd)
|
|
970
|
+
|
|
971
|
+
// Policy: within a single ACP connection (one Zed window), keep only one live pi subprocess.
|
|
972
|
+
// (Tests sometimes stub out `this.sessions`, so guard the call.)
|
|
973
|
+
;(this.sessions as any).closeAllExcept?.(session.sessionId)
|
|
974
|
+
|
|
975
|
+
// (Optional) ensure mapping stays fresh.
|
|
976
|
+
this.store.upsert({
|
|
977
|
+
sessionId: params.sessionId,
|
|
978
|
+
cwd: params.cwd,
|
|
979
|
+
sessionFile: stored.sessionFile
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
// Replay full conversation history.
|
|
983
|
+
const data = (await proc.getMessages()) as any
|
|
984
|
+
const messages = Array.isArray(data?.messages) ? data.messages : []
|
|
985
|
+
|
|
986
|
+
for (const m of messages) {
|
|
987
|
+
const role = String(m?.role ?? '')
|
|
988
|
+
|
|
989
|
+
if (role === 'user') {
|
|
990
|
+
const text = normalizePiMessageText(m?.content)
|
|
991
|
+
if (text) {
|
|
992
|
+
await this.conn.sessionUpdate({
|
|
993
|
+
sessionId: session.sessionId,
|
|
994
|
+
update: {
|
|
995
|
+
sessionUpdate: 'user_message_chunk',
|
|
996
|
+
content: { type: 'text', text }
|
|
997
|
+
}
|
|
998
|
+
})
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (role === 'assistant') {
|
|
1003
|
+
const text = normalizePiAssistantText(m?.content)
|
|
1004
|
+
if (text) {
|
|
1005
|
+
await this.conn.sessionUpdate({
|
|
1006
|
+
sessionId: session.sessionId,
|
|
1007
|
+
update: {
|
|
1008
|
+
sessionUpdate: 'agent_message_chunk',
|
|
1009
|
+
content: { type: 'text', text }
|
|
1010
|
+
}
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (role === 'toolResult') {
|
|
1016
|
+
const toolName = String((m as any)?.toolName ?? 'tool')
|
|
1017
|
+
const toolCallId = String((m as any)?.toolCallId ?? crypto.randomUUID())
|
|
1018
|
+
const isError = Boolean((m as any)?.isError)
|
|
1019
|
+
const isBash = isBashTool(toolName)
|
|
1020
|
+
|
|
1021
|
+
if (isBash) {
|
|
1022
|
+
const text = bashResultText(m)
|
|
1023
|
+
await this.conn.sessionUpdate({
|
|
1024
|
+
sessionId: session.sessionId,
|
|
1025
|
+
update: {
|
|
1026
|
+
sessionUpdate: 'tool_call',
|
|
1027
|
+
toolCallId,
|
|
1028
|
+
title: bashCommand(m) ?? toolName,
|
|
1029
|
+
kind: 'execute',
|
|
1030
|
+
status: 'completed',
|
|
1031
|
+
content: bashTerminalContent(toolCallId),
|
|
1032
|
+
_meta: bashTerminalInfoMeta(toolCallId, params.cwd)
|
|
1033
|
+
}
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
await this.conn.sessionUpdate({
|
|
1037
|
+
sessionId: session.sessionId,
|
|
1038
|
+
update: {
|
|
1039
|
+
sessionUpdate: 'tool_call_update',
|
|
1040
|
+
toolCallId,
|
|
1041
|
+
status: isError ? 'failed' : 'completed',
|
|
1042
|
+
_meta: {
|
|
1043
|
+
...(text ? bashTerminalOutputMeta(toolCallId, text) : {}),
|
|
1044
|
+
...bashTerminalExitMeta(toolCallId, bashExitCode(m, isError))
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
})
|
|
1048
|
+
continue
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Create a synthetic ACP tool call to render historic tool usage.
|
|
1052
|
+
await this.conn.sessionUpdate({
|
|
1053
|
+
sessionId: session.sessionId,
|
|
1054
|
+
update: {
|
|
1055
|
+
sessionUpdate: 'tool_call',
|
|
1056
|
+
toolCallId,
|
|
1057
|
+
title: toolName,
|
|
1058
|
+
kind: toolName === 'read' ? 'read' : toolName === 'write' || toolName === 'edit' ? 'edit' : 'other',
|
|
1059
|
+
status: 'completed',
|
|
1060
|
+
rawInput: null,
|
|
1061
|
+
rawOutput: m
|
|
1062
|
+
}
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
const text = toolResultToText(m)
|
|
1066
|
+
await this.conn.sessionUpdate({
|
|
1067
|
+
sessionId: session.sessionId,
|
|
1068
|
+
update: {
|
|
1069
|
+
sessionUpdate: 'tool_call_update',
|
|
1070
|
+
toolCallId,
|
|
1071
|
+
status: isError ? 'failed' : 'completed',
|
|
1072
|
+
content: text ? [{ type: 'content', content: { type: 'text', text } }] : null,
|
|
1073
|
+
rawOutput: m
|
|
1074
|
+
}
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const response = {
|
|
1080
|
+
configOptions,
|
|
1081
|
+
models,
|
|
1082
|
+
modes,
|
|
1083
|
+
_meta: {
|
|
1084
|
+
piAcp: {
|
|
1085
|
+
startupInfo: null
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Advertise slash commands after the response so the client knows the session exists.
|
|
1091
|
+
setTimeout(() => {
|
|
1092
|
+
void (async () => {
|
|
1093
|
+
await session.publishContextUsage()
|
|
1094
|
+
|
|
1095
|
+
try {
|
|
1096
|
+
const pi = (await proc.getCommands()) as any
|
|
1097
|
+
const { commands } = toAvailableCommandsFromPiGetCommands(pi, {
|
|
1098
|
+
enableSkillCommands,
|
|
1099
|
+
includeExtensionCommands: false
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
await this.conn.sessionUpdate({
|
|
1103
|
+
sessionId: session.sessionId,
|
|
1104
|
+
update: {
|
|
1105
|
+
sessionUpdate: 'available_commands_update',
|
|
1106
|
+
availableCommands: mergeCommands(commands, builtinAvailableCommands())
|
|
1107
|
+
}
|
|
1108
|
+
})
|
|
1109
|
+
return
|
|
1110
|
+
} catch {
|
|
1111
|
+
// fall back
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
await this.conn.sessionUpdate({
|
|
1115
|
+
sessionId: session.sessionId,
|
|
1116
|
+
update: {
|
|
1117
|
+
sessionUpdate: 'available_commands_update',
|
|
1118
|
+
availableCommands: mergeCommands(toAvailableCommands(fileCommands), builtinAvailableCommands())
|
|
1119
|
+
}
|
|
1120
|
+
})
|
|
1121
|
+
})()
|
|
1122
|
+
}, 0)
|
|
1123
|
+
|
|
1124
|
+
return response
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
|
1128
|
+
const stored = this.store.get(params.sessionId)
|
|
1129
|
+
const piSession = findPiSession(params.sessionId)
|
|
1130
|
+
|
|
1131
|
+
// Per ACP session/delete semantics, deleting a session that does not
|
|
1132
|
+
// exist (or is already gone) should succeed idempotently.
|
|
1133
|
+
// https://agentclientprotocol.com/protocol/v2/session-delete#semantics
|
|
1134
|
+
if (!stored && !piSession) {
|
|
1135
|
+
return {}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const sessionFile = stored?.sessionFile ?? piSession?.sessionFile
|
|
1139
|
+
|
|
1140
|
+
if (sessionFile) {
|
|
1141
|
+
try {
|
|
1142
|
+
if (existsSync(sessionFile)) unlinkSync(sessionFile)
|
|
1143
|
+
} catch {
|
|
1144
|
+
// best-effort cleanup
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
this.store.delete(params.sessionId)
|
|
1149
|
+
|
|
1150
|
+
return {}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async unstable_setSessionModel(params: { sessionId: string; modelId: string }): Promise<void> {
|
|
1154
|
+
const session = await this.restoreSession(params.sessionId)
|
|
1155
|
+
await setSessionModel(session.proc, params.modelId)
|
|
1156
|
+
await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc)
|
|
1157
|
+
await session.publishContextUsage()
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
|
1161
|
+
const session = await this.restoreSession(params.sessionId)
|
|
1162
|
+
|
|
1163
|
+
const mode = params.modeId
|
|
1164
|
+
if (typeof mode !== 'string' || mode.length === 0) {
|
|
1165
|
+
throw RequestError.invalidParams('Expected nonempty string modeId')
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
await session.proc.setThinkingLevel(mode)
|
|
1169
|
+
|
|
1170
|
+
await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc)
|
|
1171
|
+
|
|
1172
|
+
return {}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
|
1176
|
+
const session = await this.restoreSession(params.sessionId)
|
|
1177
|
+
const configId = String(params.configId)
|
|
1178
|
+
let modelChanged = false
|
|
1179
|
+
|
|
1180
|
+
if (typeof params.value !== 'string') {
|
|
1181
|
+
throw RequestError.invalidParams(`Expected string value for config option: ${configId}`)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (configId === MODEL_CONFIG_ID) {
|
|
1185
|
+
await setSessionModel(session.proc, params.value)
|
|
1186
|
+
modelChanged = true
|
|
1187
|
+
} else if (configId === THOUGHT_LEVEL_CONFIG_ID) {
|
|
1188
|
+
if (params.value.length === 0) {
|
|
1189
|
+
throw RequestError.invalidParams('Expected nonempty thinking level')
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
await session.proc.setThinkingLevel(params.value)
|
|
1193
|
+
} else {
|
|
1194
|
+
throw RequestError.invalidParams(`Unknown config option: ${configId}`)
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const configOptions = await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc)
|
|
1198
|
+
// A different model can mean a different context window; refresh it immediately.
|
|
1199
|
+
if (modelChanged) await session.publishContextUsage()
|
|
1200
|
+
return { configOptions }
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function getThinkingState(
|
|
1205
|
+
proc: PiRpcProcess,
|
|
1206
|
+
pre?: { state?: any | null }
|
|
1207
|
+
): Promise<{
|
|
1208
|
+
availableModes: Array<{
|
|
1209
|
+
id: string
|
|
1210
|
+
name: string
|
|
1211
|
+
description?: string | null
|
|
1212
|
+
}>
|
|
1213
|
+
currentModeId: string
|
|
1214
|
+
}> {
|
|
1215
|
+
const state = pre?.state ?? (await proc.getState())
|
|
1216
|
+
const available = await proc.getAvailableThinkingLevels()
|
|
1217
|
+
const current: unknown =
|
|
1218
|
+
state && typeof state === 'object' && 'thinkingLevel' in state ? state.thinkingLevel : undefined
|
|
1219
|
+
if (typeof current !== 'string' || current.length === 0 || !available.includes(current)) {
|
|
1220
|
+
throw new Error('pi returned a thinking level absent from available levels')
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return {
|
|
1224
|
+
currentModeId: current,
|
|
1225
|
+
availableModes: available.map(id => ({
|
|
1226
|
+
id,
|
|
1227
|
+
name: `Thinking: ${id}`,
|
|
1228
|
+
description: null
|
|
1229
|
+
}))
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
async function getSessionConfiguration(
|
|
1234
|
+
proc: PiRpcProcess,
|
|
1235
|
+
pre?: { state?: any | null; availableModels?: any | null }
|
|
1236
|
+
): Promise<{
|
|
1237
|
+
configOptions: SessionConfigOption[]
|
|
1238
|
+
models: {
|
|
1239
|
+
availableModels: AdvertisedModel[]
|
|
1240
|
+
currentModelId: string
|
|
1241
|
+
} | null
|
|
1242
|
+
modes: {
|
|
1243
|
+
availableModes: Array<{
|
|
1244
|
+
id: string
|
|
1245
|
+
name: string
|
|
1246
|
+
description?: string | null
|
|
1247
|
+
}>
|
|
1248
|
+
currentModeId: string
|
|
1249
|
+
}
|
|
1250
|
+
}> {
|
|
1251
|
+
const state = pre?.state ?? (await proc.getState())
|
|
1252
|
+
const [models, modes] = await Promise.all([getModelState(proc, { ...pre, state }), getThinkingState(proc, { state })])
|
|
1253
|
+
|
|
1254
|
+
return {
|
|
1255
|
+
configOptions: buildConfigOptions({ models, modes }),
|
|
1256
|
+
models,
|
|
1257
|
+
modes
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function buildConfigOptions(state: {
|
|
1262
|
+
models: {
|
|
1263
|
+
availableModels: AdvertisedModel[]
|
|
1264
|
+
currentModelId: string
|
|
1265
|
+
} | null
|
|
1266
|
+
modes: {
|
|
1267
|
+
availableModes: Array<{
|
|
1268
|
+
id: string
|
|
1269
|
+
name: string
|
|
1270
|
+
description?: string | null
|
|
1271
|
+
}>
|
|
1272
|
+
currentModeId: string
|
|
1273
|
+
}
|
|
1274
|
+
}): SessionConfigOption[] {
|
|
1275
|
+
const configOptions: SessionConfigOption[] = [
|
|
1276
|
+
{
|
|
1277
|
+
type: 'select',
|
|
1278
|
+
id: THOUGHT_LEVEL_CONFIG_ID,
|
|
1279
|
+
category: 'thought_level',
|
|
1280
|
+
name: 'Thinking',
|
|
1281
|
+
description: 'Set the reasoning effort for this session',
|
|
1282
|
+
currentValue: state.modes.currentModeId,
|
|
1283
|
+
options: state.modes.availableModes.map(mode => ({
|
|
1284
|
+
value: mode.id,
|
|
1285
|
+
name: mode.name,
|
|
1286
|
+
description: mode.description ?? null
|
|
1287
|
+
}))
|
|
1288
|
+
}
|
|
1289
|
+
]
|
|
1290
|
+
|
|
1291
|
+
if (state.models?.availableModels.length) {
|
|
1292
|
+
configOptions.unshift({
|
|
1293
|
+
type: 'select',
|
|
1294
|
+
id: MODEL_CONFIG_ID,
|
|
1295
|
+
category: 'model',
|
|
1296
|
+
name: 'Model',
|
|
1297
|
+
description: 'Select the model for this session',
|
|
1298
|
+
currentValue: state.models.currentModelId,
|
|
1299
|
+
options: state.models.availableModels.map(model => ({
|
|
1300
|
+
value: model.modelId,
|
|
1301
|
+
name: model.name,
|
|
1302
|
+
description: model.description ?? null
|
|
1303
|
+
}))
|
|
1304
|
+
})
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return configOptions
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async function getModelState(
|
|
1311
|
+
proc: PiRpcProcess,
|
|
1312
|
+
pre?: { state?: any | null; availableModels?: any | null }
|
|
1313
|
+
): Promise<{
|
|
1314
|
+
availableModels: AdvertisedModel[]
|
|
1315
|
+
currentModelId: string
|
|
1316
|
+
} | null> {
|
|
1317
|
+
// Ask pi for available models.
|
|
1318
|
+
let availableModels: AdvertisedModel[] = []
|
|
1319
|
+
|
|
1320
|
+
const data =
|
|
1321
|
+
pre?.availableModels ??
|
|
1322
|
+
(await (async () => {
|
|
1323
|
+
try {
|
|
1324
|
+
return (await proc.getAvailableModels()) as any
|
|
1325
|
+
} catch {
|
|
1326
|
+
return null
|
|
1327
|
+
}
|
|
1328
|
+
})())
|
|
1329
|
+
|
|
1330
|
+
const models: any[] = Array.isArray(data?.models) ? data.models : []
|
|
1331
|
+
availableModels = models
|
|
1332
|
+
.map(m => {
|
|
1333
|
+
const provider = String(m?.provider ?? '').trim()
|
|
1334
|
+
const id = String(m?.id ?? '').trim()
|
|
1335
|
+
if (!provider || !id) return null
|
|
1336
|
+
|
|
1337
|
+
const name = String(m?.name ?? id)
|
|
1338
|
+
return {
|
|
1339
|
+
modelId: `${provider}/${id}`,
|
|
1340
|
+
name: `${provider}/${name}`,
|
|
1341
|
+
description: null
|
|
1342
|
+
} satisfies AdvertisedModel
|
|
1343
|
+
})
|
|
1344
|
+
.filter(Boolean) as AdvertisedModel[]
|
|
1345
|
+
|
|
1346
|
+
// Ask pi what model is currently active.
|
|
1347
|
+
let currentModelId: string | null = null
|
|
1348
|
+
|
|
1349
|
+
const state =
|
|
1350
|
+
pre?.state ??
|
|
1351
|
+
(await (async () => {
|
|
1352
|
+
try {
|
|
1353
|
+
return (await proc.getState()) as any
|
|
1354
|
+
} catch {
|
|
1355
|
+
return null
|
|
1356
|
+
}
|
|
1357
|
+
})())
|
|
1358
|
+
|
|
1359
|
+
const model = state?.model
|
|
1360
|
+
if (model && typeof model === 'object') {
|
|
1361
|
+
const provider = String((model as any).provider ?? '').trim()
|
|
1362
|
+
const id = String((model as any).id ?? '').trim()
|
|
1363
|
+
if (provider && id) currentModelId = `${provider}/${id}`
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (!availableModels.length && !currentModelId) return null
|
|
1367
|
+
|
|
1368
|
+
// Fallback if current model is unknown: use first in list.
|
|
1369
|
+
if (!currentModelId) currentModelId = availableModels[0]?.modelId ?? 'default'
|
|
1370
|
+
|
|
1371
|
+
return {
|
|
1372
|
+
availableModels,
|
|
1373
|
+
currentModelId: currentModelId ?? availableModels[0]?.modelId ?? 'default'
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async function emitConfigOptionsUpdate(
|
|
1378
|
+
conn: AgentSideConnection,
|
|
1379
|
+
sessionId: string,
|
|
1380
|
+
proc: PiRpcProcess
|
|
1381
|
+
): Promise<SessionConfigOption[]> {
|
|
1382
|
+
const { configOptions, modes } = await getSessionConfiguration(proc)
|
|
1383
|
+
|
|
1384
|
+
await conn.sessionUpdate({
|
|
1385
|
+
sessionId,
|
|
1386
|
+
update: { sessionUpdate: 'current_mode_update', currentModeId: modes.currentModeId }
|
|
1387
|
+
})
|
|
1388
|
+
await conn.sessionUpdate({
|
|
1389
|
+
sessionId,
|
|
1390
|
+
update: {
|
|
1391
|
+
sessionUpdate: 'config_option_update',
|
|
1392
|
+
configOptions
|
|
1393
|
+
}
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1396
|
+
return configOptions
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
async function setSessionModel(proc: PiRpcProcess, requestedModelId: string): Promise<void> {
|
|
1400
|
+
// Accept either:
|
|
1401
|
+
// - "provider/model" (preferred, matches how we advertise)
|
|
1402
|
+
// - "model" (fallback, resolve via available models)
|
|
1403
|
+
let provider: string | null = null
|
|
1404
|
+
let modelId: string | null = null
|
|
1405
|
+
|
|
1406
|
+
if (requestedModelId.includes('/')) {
|
|
1407
|
+
const [candidateProvider, ...rest] = requestedModelId.split('/')
|
|
1408
|
+
provider = candidateProvider
|
|
1409
|
+
modelId = rest.join('/')
|
|
1410
|
+
} else {
|
|
1411
|
+
modelId = requestedModelId
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
if (!provider) {
|
|
1415
|
+
const data = (await proc.getAvailableModels()) as any
|
|
1416
|
+
const models: any[] = Array.isArray(data?.models) ? data.models : []
|
|
1417
|
+
const found = models.find(m => String(m?.id) === modelId)
|
|
1418
|
+
if (found) {
|
|
1419
|
+
provider = String(found.provider)
|
|
1420
|
+
modelId = String(found.id)
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (!provider || !modelId) {
|
|
1425
|
+
throw RequestError.invalidParams(`Unknown modelId: ${requestedModelId}`)
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
await proc.setModel(provider, modelId)
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function isSemver(v: string): boolean {
|
|
1432
|
+
return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(v)
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function compareSemver(a: string, b: string): number {
|
|
1436
|
+
// Very small comparator for x.y.z (ignores pre-release/build beyond making them "not greater" unless base differs)
|
|
1437
|
+
const pa = a
|
|
1438
|
+
.split(/[.-]/)
|
|
1439
|
+
.slice(0, 3)
|
|
1440
|
+
.map(n => Number(n))
|
|
1441
|
+
const pb = b
|
|
1442
|
+
.split(/[.-]/)
|
|
1443
|
+
.slice(0, 3)
|
|
1444
|
+
.map(n => Number(n))
|
|
1445
|
+
for (let i = 0; i < 3; i++) {
|
|
1446
|
+
const da = pa[i] ?? 0
|
|
1447
|
+
const db = pb[i] ?? 0
|
|
1448
|
+
if (da > db) return 1
|
|
1449
|
+
if (da < db) return -1
|
|
1450
|
+
}
|
|
1451
|
+
return 0
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function buildUpdateNotice(): string | null {
|
|
1455
|
+
// Best-effort update check against npm registry.
|
|
1456
|
+
// Important: keep it fast to not slow down session/new.
|
|
1457
|
+
try {
|
|
1458
|
+
const piVersion = spawnSync('pi', ['--version'], { encoding: 'utf-8' })
|
|
1459
|
+
const installed = (String(piVersion.stdout ?? '').trim() || String(piVersion.stderr ?? '').trim()).replace(
|
|
1460
|
+
/^v/i,
|
|
1461
|
+
''
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
if (!installed || !isSemver(installed)) return null
|
|
1465
|
+
|
|
1466
|
+
const latestRes = spawnSync('npm', ['view', '@earendil-works/pi-coding-agent', 'version'], {
|
|
1467
|
+
encoding: 'utf-8',
|
|
1468
|
+
timeout: 800
|
|
1469
|
+
})
|
|
1470
|
+
const latest = String(latestRes.stdout ?? '')
|
|
1471
|
+
.trim()
|
|
1472
|
+
.replace(/^v/i, '')
|
|
1473
|
+
|
|
1474
|
+
if (!latest || !isSemver(latest)) return null
|
|
1475
|
+
if (compareSemver(latest, installed) <= 0) return null
|
|
1476
|
+
|
|
1477
|
+
return `New version available: v${latest} (installed v${installed}). Run: \`npm i -g @earendil-works/pi-coding-agent\``
|
|
1478
|
+
} catch {
|
|
1479
|
+
return null
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function buildStartupInfo(opts: {
|
|
1484
|
+
cwd: string
|
|
1485
|
+
fileCommands: ReturnType<typeof loadSlashCommands>
|
|
1486
|
+
updateNotice: string | null
|
|
1487
|
+
}): string {
|
|
1488
|
+
void opts.fileCommands
|
|
1489
|
+
|
|
1490
|
+
const md: string[] = []
|
|
1491
|
+
|
|
1492
|
+
// pi version header
|
|
1493
|
+
try {
|
|
1494
|
+
const piVersion = spawnSync('pi', ['--version'], { encoding: 'utf-8' })
|
|
1495
|
+
const installed = (String(piVersion.stdout ?? '').trim() || String(piVersion.stderr ?? '').trim()).replace(
|
|
1496
|
+
/^v/i,
|
|
1497
|
+
''
|
|
1498
|
+
)
|
|
1499
|
+
if (installed) {
|
|
1500
|
+
md.push(`pi v${installed}`)
|
|
1501
|
+
md.push('---')
|
|
1502
|
+
md.push('')
|
|
1503
|
+
}
|
|
1504
|
+
} catch {
|
|
1505
|
+
// ignore
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const addSection = (title: string, items: string[]) => {
|
|
1509
|
+
const cleaned = items.map(s => s.trim()).filter(Boolean)
|
|
1510
|
+
if (!cleaned.length) return
|
|
1511
|
+
|
|
1512
|
+
md.push(`## ${title}`)
|
|
1513
|
+
for (const item of cleaned) md.push(`- ${item}`)
|
|
1514
|
+
md.push('')
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Context
|
|
1518
|
+
const contextItems: string[] = []
|
|
1519
|
+
const contextPath = join(opts.cwd, 'AGENTS.md')
|
|
1520
|
+
if (existsSync(contextPath)) contextItems.push(contextPath)
|
|
1521
|
+
addSection('Context', contextItems)
|
|
1522
|
+
|
|
1523
|
+
// Skills
|
|
1524
|
+
const skillsItems: string[] = []
|
|
1525
|
+
|
|
1526
|
+
const pushSkillFromRoot = (root: string) => {
|
|
1527
|
+
try {
|
|
1528
|
+
// Direct .md files in root
|
|
1529
|
+
for (const e of readdirSync(root)) {
|
|
1530
|
+
const p = join(root, e)
|
|
1531
|
+
try {
|
|
1532
|
+
const st = statSync(p)
|
|
1533
|
+
if (st.isFile() && e.toLowerCase().endsWith('.md')) {
|
|
1534
|
+
skillsItems.push(p)
|
|
1535
|
+
}
|
|
1536
|
+
} catch {
|
|
1537
|
+
// ignore
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Recursive SKILL.md under subdirectories
|
|
1542
|
+
const stack: string[] = [root]
|
|
1543
|
+
while (stack.length) {
|
|
1544
|
+
const dir = stack.pop()!
|
|
1545
|
+
let entries: string[] = []
|
|
1546
|
+
try {
|
|
1547
|
+
entries = readdirSync(dir)
|
|
1548
|
+
} catch {
|
|
1549
|
+
continue
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
for (const name of entries) {
|
|
1553
|
+
// Skip obvious noise
|
|
1554
|
+
if (name === 'node_modules' || name === '.git') continue
|
|
1555
|
+
const p = join(dir, name)
|
|
1556
|
+
let st
|
|
1557
|
+
try {
|
|
1558
|
+
st = statSync(p)
|
|
1559
|
+
} catch {
|
|
1560
|
+
continue
|
|
1561
|
+
}
|
|
1562
|
+
if (st.isDirectory()) {
|
|
1563
|
+
stack.push(p)
|
|
1564
|
+
} else if (st.isFile() && name === 'SKILL.md') {
|
|
1565
|
+
skillsItems.push(p)
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
} catch {
|
|
1570
|
+
// ignore
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Global skills
|
|
1575
|
+
// Use getAgentDir() so this respects PI_CODING_AGENT_DIR overrides.
|
|
1576
|
+
const globalSkillsDir = join(getAgentDir(), 'skills')
|
|
1577
|
+
pushSkillFromRoot(globalSkillsDir)
|
|
1578
|
+
|
|
1579
|
+
// Also support ~/.agents/skills (pi skill discovery)
|
|
1580
|
+
const legacyAgentsSkillsDir = join(process.env.HOME ?? '', '.agents', 'skills')
|
|
1581
|
+
pushSkillFromRoot(legacyAgentsSkillsDir)
|
|
1582
|
+
|
|
1583
|
+
// Project skills (.pi/skills)
|
|
1584
|
+
const projectSkillsDir = join(opts.cwd, '.pi', 'skills')
|
|
1585
|
+
pushSkillFromRoot(projectSkillsDir)
|
|
1586
|
+
|
|
1587
|
+
addSection('Skills', skillsItems)
|
|
1588
|
+
|
|
1589
|
+
// Prompts
|
|
1590
|
+
const promptsItems: string[] = []
|
|
1591
|
+
const promptsDir = join(process.env.HOME ?? '', '.pi', 'agent', 'prompts')
|
|
1592
|
+
try {
|
|
1593
|
+
const prompts = readdirSync(promptsDir).filter(f => f.endsWith('.md'))
|
|
1594
|
+
for (const f of prompts) promptsItems.push(`/${basename(f, '.md')}`)
|
|
1595
|
+
} catch {
|
|
1596
|
+
// ignore
|
|
1597
|
+
}
|
|
1598
|
+
addSection('Prompts', promptsItems)
|
|
1599
|
+
|
|
1600
|
+
// Extensions
|
|
1601
|
+
const extItems: string[] = []
|
|
1602
|
+
const extDir = join(process.env.HOME ?? '', '.pi', 'agent', 'extensions')
|
|
1603
|
+
try {
|
|
1604
|
+
const exts = readdirSync(extDir).filter(f => f.endsWith('.ts') || f.endsWith('.js'))
|
|
1605
|
+
for (const f of exts) extItems.push(join(extDir, f))
|
|
1606
|
+
} catch {
|
|
1607
|
+
// ignore
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Also show npm packages from pi settings (global + project)
|
|
1611
|
+
const settingsPaths = [join(getAgentDir(), 'settings.json'), join(opts.cwd, '.pi', 'settings.json')]
|
|
1612
|
+
for (const settingsPath of settingsPaths) {
|
|
1613
|
+
try {
|
|
1614
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as any
|
|
1615
|
+
const pkgs: string[] = Array.isArray(settings?.packages) ? settings.packages : []
|
|
1616
|
+
for (const pkg of pkgs) {
|
|
1617
|
+
const s = String(pkg)
|
|
1618
|
+
if (s.startsWith('npm:')) {
|
|
1619
|
+
extItems.push(`${s}\n - index.ts`)
|
|
1620
|
+
} else {
|
|
1621
|
+
extItems.push(s)
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
} catch {
|
|
1625
|
+
// ignore
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
addSection('Extensions', extItems)
|
|
1630
|
+
|
|
1631
|
+
if (opts.updateNotice) {
|
|
1632
|
+
md.push('---')
|
|
1633
|
+
md.push(opts.updateNotice)
|
|
1634
|
+
md.push('')
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Do NOT include themes (per request).
|
|
1638
|
+
return md.join('\n').trim() + '\n'
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function readNearestPackageJson(metaUrl: string): {
|
|
1642
|
+
name?: string
|
|
1643
|
+
version?: string
|
|
1644
|
+
} {
|
|
1645
|
+
try {
|
|
1646
|
+
let dir = dirname(fileURLToPath(metaUrl))
|
|
1647
|
+
|
|
1648
|
+
// Walk upwards a few levels to find the nearest package.json
|
|
1649
|
+
for (let i = 0; i < 6; i++) {
|
|
1650
|
+
const p = join(dir, 'package.json')
|
|
1651
|
+
if (existsSync(p)) {
|
|
1652
|
+
const json = JSON.parse(readFileSync(p, 'utf-8')) as any
|
|
1653
|
+
return { name: json?.name, version: json?.version }
|
|
1654
|
+
}
|
|
1655
|
+
dir = dirname(dir)
|
|
1656
|
+
}
|
|
1657
|
+
} catch {
|
|
1658
|
+
// ignore
|
|
1659
|
+
}
|
|
1660
|
+
return { name: 'pi-acp', version: '0.0.0' }
|
|
1661
|
+
}
|