@pi-in-go/pigpen-acp 0.1.0

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