@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,1078 @@
1
+ import type {
2
+ AgentSideConnection,
3
+ ContentBlock,
4
+ McpServer,
5
+ PermissionOption,
6
+ SessionUpdate,
7
+ ToolCallContent,
8
+ ToolCallLocation,
9
+ ToolKind
10
+ } from '@agentclientprotocol/sdk'
11
+ import { RequestError } from '@agentclientprotocol/sdk'
12
+ import { readFileSync } from 'node:fs'
13
+ import { isAbsolute, resolve as resolvePath } from 'node:path'
14
+ import {
15
+ PiRpcProcess,
16
+ PiRpcSpawnError,
17
+ SESSION_STATS_TIMEOUT_MS,
18
+ type PiRpcEvent,
19
+ type PiSessionStats
20
+ } from '../pi-rpc/process.js'
21
+ import { maybeAuthRequiredError } from './auth-required.js'
22
+ import { SessionStore } from './session-store.js'
23
+ import { expandSlashCommand, type FileSlashCommand } from './slash-commands.js'
24
+ import {
25
+ bashCommand,
26
+ bashExitCode,
27
+ bashOutputDelta,
28
+ bashResultText,
29
+ bashTerminalContent,
30
+ bashTerminalExitMeta,
31
+ bashTerminalInfoMeta,
32
+ bashTerminalOutputMeta,
33
+ isBashTool
34
+ } from './translate/bash.js'
35
+ import { toolResultToText } from './translate/pi-tools.js'
36
+
37
+ type SessionCreateParams = {
38
+ cwd: string
39
+ mcpServers: McpServer[]
40
+ conn: AgentSideConnection
41
+ fileCommands?: import('./slash-commands.js').FileSlashCommand[]
42
+ piCommand?: string
43
+ }
44
+
45
+ export type StopReason = 'end_turn' | 'cancelled' | 'error'
46
+
47
+ type PendingTurn = {
48
+ resolve: (reason: StopReason) => void
49
+ reject: (err: unknown) => void
50
+ }
51
+
52
+ type QueuedTurn = {
53
+ message: string
54
+ images: unknown[]
55
+ resolve: (reason: StopReason) => void
56
+ reject: (err: unknown) => void
57
+ }
58
+
59
+ type PermissionResponse = Awaited<ReturnType<AgentSideConnection['requestPermission']>>
60
+
61
+ const CONFIRM_PERMISSION_OPTIONS: PermissionOption[] = [
62
+ { optionId: 'yes', name: 'Yes', kind: 'allow_once' },
63
+ { optionId: 'no', name: 'No', kind: 'reject_once' }
64
+ ]
65
+ const EXTENSION_UI_RAW_INPUT_KEYS = ['title', 'message', 'options', 'placeholder', 'prefill'] as const
66
+ const CHOICE_OPTION_PREFIX = 'choice-'
67
+
68
+ /**
69
+ * Map pi's `stats.contextUsage` to an ACP `usage_update`. Returns null whenever pi
70
+ * reports no trustworthy token count (e.g. `tokens: null` right after compaction) or
71
+ * the values are not usable integers.
72
+ */
73
+ function toUsageUpdate(stats: PiSessionStats | null | undefined): SessionUpdate | null {
74
+ const used = stats?.contextUsage?.tokens
75
+ const size = stats?.contextUsage?.contextWindow
76
+
77
+ if (typeof used !== 'number' || !Number.isSafeInteger(used) || used < 0) return null
78
+ if (typeof size !== 'number' || !Number.isSafeInteger(size) || size <= 0) return null
79
+
80
+ return { sessionUpdate: 'usage_update', used, size }
81
+ }
82
+
83
+ function findUniqueLineNumber(text: string, needle: string): number | undefined {
84
+ if (!needle) return undefined
85
+
86
+ const first = text.indexOf(needle)
87
+ if (first < 0) return undefined
88
+
89
+ const second = text.indexOf(needle, first + needle.length)
90
+ if (second >= 0) return undefined
91
+
92
+ let line = 1
93
+ for (let i = 0; i < first; i += 1) {
94
+ if (text.charCodeAt(i) === 10) line += 1
95
+ }
96
+ return line
97
+ }
98
+
99
+ function getToolPath(args: unknown): string | undefined {
100
+ const record = args as { path?: unknown; file_path?: unknown } | null | undefined
101
+ if (typeof record?.path === 'string') return record.path
102
+ if (typeof record?.file_path === 'string') return record.file_path
103
+ return undefined
104
+ }
105
+
106
+ // Match pi's current edit schema: { path, edits: [{ oldText, newText }] }, with
107
+ // legacy top-level oldText/newText still accepted. Pi also normalizes stringified edits.
108
+ // https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts
109
+ function getParsedEdits(args: unknown): Array<{ oldText: string; newText: string }> {
110
+ const record = args as { oldText?: unknown; newText?: unknown; edits?: unknown } | null | undefined
111
+ const parsed: Array<{ oldText: string; newText: string }> = []
112
+
113
+ if (typeof record?.oldText === 'string' && typeof record?.newText === 'string') {
114
+ parsed.push({ oldText: record.oldText, newText: record.newText })
115
+ }
116
+
117
+ let edits = record?.edits
118
+ if (typeof edits === 'string') {
119
+ try {
120
+ edits = JSON.parse(edits) as unknown
121
+ } catch {
122
+ edits = undefined
123
+ }
124
+ }
125
+
126
+ if (Array.isArray(edits)) {
127
+ for (const edit of edits) {
128
+ const item = edit as { oldText?: unknown; newText?: unknown } | null | undefined
129
+ if (typeof item?.oldText === 'string' && typeof item?.newText === 'string') {
130
+ parsed.push({ oldText: item.oldText, newText: item.newText })
131
+ }
132
+ }
133
+ }
134
+
135
+ return parsed
136
+ }
137
+
138
+ function getEditOldTexts(args: unknown): string[] {
139
+ const record = args as { oldText?: unknown; edits?: unknown } | null | undefined
140
+ const oldTexts = getParsedEdits(args).map(edit => edit.oldText)
141
+
142
+ if (typeof record?.oldText === 'string' && !oldTexts.includes(record.oldText)) oldTexts.push(record.oldText)
143
+
144
+ let edits = record?.edits
145
+ if (typeof edits === 'string') {
146
+ try {
147
+ edits = JSON.parse(edits) as unknown
148
+ } catch {
149
+ edits = undefined
150
+ }
151
+ }
152
+
153
+ if (Array.isArray(edits)) {
154
+ for (const edit of edits) {
155
+ const oldText = (edit as { oldText?: unknown } | null | undefined)?.oldText
156
+ if (typeof oldText === 'string' && !oldTexts.includes(oldText)) oldTexts.push(oldText)
157
+ }
158
+ }
159
+
160
+ return oldTexts
161
+ }
162
+
163
+ function toToolCallLocations(args: unknown, cwd: string, line?: number): ToolCallLocation[] | undefined {
164
+ const path = getToolPath(args)
165
+ if (!path) return undefined
166
+
167
+ const resolvedPath = isAbsolute(path) ? path : resolvePath(cwd, path)
168
+ return [{ path: resolvedPath, ...(typeof line === 'number' ? { line } : {}) }]
169
+ }
170
+
171
+ export class SessionManager {
172
+ private sessions = new Map<string, PiAcpSession>()
173
+ private readonly store = new SessionStore()
174
+
175
+ /** Dispose all sessions and their underlying pi subprocesses. */
176
+ disposeAll(): void {
177
+ for (const [id] of this.sessions) this.close(id)
178
+ }
179
+
180
+ /** Get a registered session if it exists (no throw). */
181
+ maybeGet(sessionId: string): PiAcpSession | undefined {
182
+ return this.sessions.get(sessionId)
183
+ }
184
+
185
+ /**
186
+ * Dispose a session's underlying pi process and remove it from the manager.
187
+ * Used when clients explicitly reload a session and we want a fresh pi subprocess.
188
+ */
189
+ close(sessionId: string): void {
190
+ const s = this.sessions.get(sessionId)
191
+ if (!s) return
192
+ try {
193
+ s.proc.dispose?.()
194
+ } catch {
195
+ // ignore
196
+ }
197
+ this.sessions.delete(sessionId)
198
+ }
199
+
200
+ /** Close all sessions except the one with `keepSessionId`. */
201
+ closeAllExcept(keepSessionId: string): void {
202
+ for (const [id] of this.sessions) {
203
+ if (id === keepSessionId) continue
204
+ this.close(id)
205
+ }
206
+ }
207
+
208
+ async create(params: SessionCreateParams): Promise<PiAcpSession> {
209
+ // Let pi manage session persistence in its default location (~/.pi/agent/sessions/...)
210
+ // so sessions are visible to the regular `pi` CLI.
211
+ let proc: PiRpcProcess
212
+ try {
213
+ proc = await PiRpcProcess.spawn({
214
+ cwd: params.cwd,
215
+ piCommand: params.piCommand
216
+ })
217
+ } catch (e) {
218
+ if (e instanceof PiRpcSpawnError) {
219
+ throw RequestError.internalError({ code: e.code }, e.message)
220
+ }
221
+ throw e
222
+ }
223
+
224
+ let state: any = null
225
+ try {
226
+ state = (await proc.getState()) as any
227
+ } catch {
228
+ state = null
229
+ }
230
+
231
+ const sessionId = typeof state?.sessionId === 'string' ? state.sessionId : crypto.randomUUID()
232
+ const sessionFile = typeof state?.sessionFile === 'string' ? state.sessionFile : null
233
+
234
+ if (sessionFile) {
235
+ this.store.upsert({ sessionId, cwd: params.cwd, sessionFile })
236
+ }
237
+
238
+ const session = new PiAcpSession({
239
+ sessionId,
240
+ cwd: params.cwd,
241
+ mcpServers: params.mcpServers,
242
+ proc,
243
+ conn: params.conn,
244
+ fileCommands: params.fileCommands ?? []
245
+ })
246
+
247
+ this.sessions.set(sessionId, session)
248
+ return session
249
+ }
250
+
251
+ get(sessionId: string): PiAcpSession {
252
+ const s = this.sessions.get(sessionId)
253
+ if (!s) throw RequestError.invalidParams(`Unknown sessionId: ${sessionId}`)
254
+ return s
255
+ }
256
+
257
+ /**
258
+ * Used by session/load: create a session object bound to an existing sessionId/proc
259
+ * if it isn't already registered.
260
+ */
261
+ getOrCreate(sessionId: string, params: SessionCreateParams & { proc: PiRpcProcess }): PiAcpSession {
262
+ const existing = this.sessions.get(sessionId)
263
+ if (existing) return existing
264
+
265
+ const session = new PiAcpSession({
266
+ sessionId,
267
+ cwd: params.cwd,
268
+ mcpServers: params.mcpServers,
269
+ proc: params.proc,
270
+ conn: params.conn,
271
+ fileCommands: params.fileCommands ?? []
272
+ })
273
+
274
+ this.sessions.set(sessionId, session)
275
+ return session
276
+ }
277
+ }
278
+
279
+ export class PiAcpSession {
280
+ readonly sessionId: string
281
+ readonly cwd: string
282
+ readonly mcpServers: McpServer[]
283
+
284
+ private startupInfo: string | null = null
285
+ private startupInfoSent = false
286
+
287
+ readonly proc: PiRpcProcess
288
+ private readonly conn: AgentSideConnection
289
+ private readonly fileCommands: FileSlashCommand[]
290
+
291
+ // Used to map abort semantics to ACP stopReason.
292
+ // Applies to the currently running turn.
293
+ private cancelRequested = false
294
+
295
+ // Current in-flight turn (if any). Additional prompts are queued.
296
+ private pendingTurn: PendingTurn | null = null
297
+ private readonly turnQueue: QueuedTurn[] = []
298
+ // Track tool call statuses and ensure they are monotonic (pending -> in_progress -> completed).
299
+ // Some pi events can arrive out of order (e.g. late toolcall_* deltas after execution starts),
300
+ // and clients may hide progress if we ever downgrade back to `pending`.
301
+ private currentToolCalls = new Map<string, 'pending' | 'in_progress'>()
302
+
303
+ // pi can emit multiple `turn_end` and `agent_end` events for a single user prompt
304
+ // when retry, compaction, or queued continuations run. The session-level prompt
305
+ // completes only when `agent_settled` is emitted.
306
+ private inAgentLoop = false
307
+
308
+ // For ACP diff support: capture file contents before edit/write mutations,
309
+ // then emit ToolCallContent {type:"diff"}. Compatible structured edit/write
310
+ // events may need to be implemented in pi in the future.
311
+ private fileSnapshots = new Map<string, { path: string; oldText: string | null }>()
312
+ private fileMutationToolCallIds = new Set<string>()
313
+ private bashToolCallIds = new Set<string>()
314
+ private bashOutputSnapshots = new Map<string, string>()
315
+
316
+ // Ensure `session/update` notifications are sent in order and can be awaited
317
+ // before completing a `session/prompt` request.
318
+ private lastEmit: Promise<void> = Promise.resolve()
319
+
320
+ constructor(opts: {
321
+ sessionId: string
322
+ cwd: string
323
+ mcpServers: McpServer[]
324
+ proc: PiRpcProcess
325
+ conn: AgentSideConnection
326
+ fileCommands?: FileSlashCommand[]
327
+ }) {
328
+ this.sessionId = opts.sessionId
329
+ this.cwd = opts.cwd
330
+ this.mcpServers = opts.mcpServers
331
+ this.proc = opts.proc
332
+ this.conn = opts.conn
333
+ this.fileCommands = opts.fileCommands ?? []
334
+
335
+ this.proc.onEvent(ev => this.handlePiEvent(ev))
336
+ }
337
+
338
+ setStartupInfo(text: string) {
339
+ this.startupInfo = text
340
+ this.startupInfoSent = false
341
+ }
342
+
343
+ /**
344
+ * Best-effort attempt to send startup info outside of a prompt turn.
345
+ * Some clients (e.g. Zed) may only render agent messages once the UI is ready;
346
+ * callers can invoke this shortly after session/new returns.
347
+ */
348
+ sendStartupInfoIfPending(): void {
349
+ if (this.startupInfoSent || !this.startupInfo) return
350
+ this.startupInfoSent = true
351
+
352
+ this.emit({
353
+ sessionUpdate: 'agent_message_chunk',
354
+ content: { type: 'text', text: this.startupInfo }
355
+ })
356
+ }
357
+
358
+ async prompt(message: string, images: unknown[] = []): Promise<StopReason> {
359
+ // pi RPC mode disables slash command expansion, so we do it here.
360
+ const expandedMessage = expandSlashCommand(message, this.fileCommands)
361
+
362
+ const turnPromise = new Promise<StopReason>((resolve, reject) => {
363
+ const queued: QueuedTurn = { message: expandedMessage, images, resolve, reject }
364
+
365
+ // If a turn is already running, enqueue.
366
+ if (this.pendingTurn) {
367
+ this.turnQueue.push(queued)
368
+
369
+ // Best-effort: notify client that a prompt was queued.
370
+ // This doesn't work in Zed yet, needs to be revisited
371
+ this.emit({
372
+ sessionUpdate: 'agent_message_chunk',
373
+ content: {
374
+ type: 'text',
375
+ text: `Queued message (position ${this.turnQueue.length}).`
376
+ }
377
+ })
378
+
379
+ // Also publish queue depth via session info metadata.
380
+ // This also not visible in the client
381
+ this.emit({
382
+ sessionUpdate: 'session_info_update',
383
+ _meta: { piAcp: { queueDepth: this.turnQueue.length, running: true } }
384
+ })
385
+
386
+ return
387
+ }
388
+
389
+ // No turn is running; start immediately.
390
+ this.startTurn(queued)
391
+ })
392
+
393
+ return turnPromise
394
+ }
395
+
396
+ async cancel(): Promise<void> {
397
+ // Cancel current and clear any queued prompts.
398
+ this.cancelRequested = true
399
+
400
+ if (this.turnQueue.length) {
401
+ const queued = this.turnQueue.splice(0, this.turnQueue.length)
402
+ for (const t of queued) t.resolve('cancelled')
403
+
404
+ this.emit({
405
+ sessionUpdate: 'agent_message_chunk',
406
+ content: { type: 'text', text: 'Cleared queued prompts.' }
407
+ })
408
+ this.emit({
409
+ sessionUpdate: 'session_info_update',
410
+ _meta: { piAcp: { queueDepth: 0, running: Boolean(this.pendingTurn) } }
411
+ })
412
+ }
413
+
414
+ // Abort the currently running turn (if any). If nothing is running, this is a no-op.
415
+ await this.proc.abort()
416
+ }
417
+
418
+ wasCancelRequested(): boolean {
419
+ return this.cancelRequested
420
+ }
421
+
422
+ private emit(update: SessionUpdate): void {
423
+ // Serialize update delivery.
424
+ this.lastEmit = this.lastEmit
425
+ .then(() =>
426
+ this.conn.sessionUpdate({
427
+ sessionId: this.sessionId,
428
+ update
429
+ })
430
+ )
431
+ .catch(() => {
432
+ // Ignore notification errors (client may have gone away). We still want
433
+ // prompt completion.
434
+ })
435
+ }
436
+
437
+ private async flushEmits(): Promise<void> {
438
+ await this.lastEmit
439
+ }
440
+
441
+ /**
442
+ * Best-effort: publish the real pi context-window occupancy as ACP `usage_update`.
443
+ * Queued updates are flushed even when the stats query fails or times out, so callers
444
+ * can await this before resolving `session/prompt`.
445
+ */
446
+ async publishContextUsage(): Promise<void> {
447
+ try {
448
+ // Older/stubbed pi processes may not expose the stats RPC at all.
449
+ if (typeof this.proc.getSessionStats === 'function') {
450
+ const update = toUsageUpdate(await this.proc.getSessionStats(SESSION_STATS_TIMEOUT_MS))
451
+ if (update) this.emit(update)
452
+ }
453
+ } catch {
454
+ // Context usage is auxiliary; never fail or delay the turn because of it.
455
+ }
456
+
457
+ await this.flushEmits()
458
+ }
459
+
460
+ private async settleTurn(): Promise<void> {
461
+ // Ensure all updates derived from pi events (plus the final usage update) are
462
+ // delivered before we resolve the ACP `session/prompt` request.
463
+ await this.publishContextUsage()
464
+
465
+ const reason: StopReason = this.cancelRequested ? 'cancelled' : 'end_turn'
466
+ this.pendingTurn?.resolve(reason)
467
+ this.pendingTurn = null
468
+ this.inAgentLoop = false
469
+
470
+ // Start next queued prompt, if any.
471
+ const next = this.turnQueue.shift()
472
+ if (next) {
473
+ this.emit({
474
+ sessionUpdate: 'agent_message_chunk',
475
+ content: { type: 'text', text: `Starting queued message. (${this.turnQueue.length} remaining)` }
476
+ })
477
+ this.startTurn(next)
478
+ } else {
479
+ this.emit({
480
+ sessionUpdate: 'session_info_update',
481
+ _meta: { piAcp: { queueDepth: 0, running: false } }
482
+ })
483
+ }
484
+ }
485
+
486
+ private emitBashToolCall(params: {
487
+ sessionUpdate: 'tool_call' | 'tool_call_update'
488
+ toolCallId: string
489
+ toolName: string
490
+ args: unknown
491
+ status: 'pending' | 'in_progress'
492
+ locations?: ToolCallLocation[]
493
+ includeTerminal: boolean
494
+ }): void {
495
+ this.bashToolCallIds.add(params.toolCallId)
496
+ this.emit({
497
+ sessionUpdate: params.sessionUpdate,
498
+ toolCallId: params.toolCallId,
499
+ title: bashCommand(params.args) ?? params.toolName,
500
+ kind: 'execute',
501
+ status: params.status,
502
+ locations: params.locations,
503
+ ...(params.includeTerminal ? { content: bashTerminalContent(params.toolCallId) } : {}),
504
+ ...(params.includeTerminal ? { _meta: bashTerminalInfoMeta(params.toolCallId, this.cwd) } : {})
505
+ })
506
+ }
507
+
508
+ private emitBashOutputUpdate(params: {
509
+ toolCallId: string
510
+ status: 'in_progress' | 'completed' | 'failed'
511
+ result: unknown
512
+ isError?: boolean
513
+ }): void {
514
+ const text = bashResultText(params.result)
515
+ const previous = this.bashOutputSnapshots.get(params.toolCallId) ?? ''
516
+ const delta = bashOutputDelta(previous, text)
517
+ this.bashOutputSnapshots.set(params.toolCallId, text)
518
+
519
+ this.emit({
520
+ sessionUpdate: 'tool_call_update',
521
+ toolCallId: params.toolCallId,
522
+ status: params.status,
523
+ _meta: {
524
+ ...(delta ? bashTerminalOutputMeta(params.toolCallId, delta) : {}),
525
+ ...(params.status === 'completed' || params.status === 'failed'
526
+ ? bashTerminalExitMeta(params.toolCallId, bashExitCode(params.result, Boolean(params.isError)))
527
+ : {})
528
+ }
529
+ })
530
+ }
531
+
532
+ private cleanupToolCall(toolCallId: string): void {
533
+ this.currentToolCalls.delete(toolCallId)
534
+ this.fileSnapshots.delete(toolCallId)
535
+ this.fileMutationToolCallIds.delete(toolCallId)
536
+ this.bashToolCallIds.delete(toolCallId)
537
+ this.bashOutputSnapshots.delete(toolCallId)
538
+ }
539
+
540
+ private startTurn(t: QueuedTurn): void {
541
+ this.cancelRequested = false
542
+ this.inAgentLoop = false
543
+
544
+ this.pendingTurn = { resolve: t.resolve, reject: t.reject }
545
+
546
+ // Publish queue depth (0 because we're starting the turn now).
547
+ this.emit({
548
+ sessionUpdate: 'session_info_update',
549
+ _meta: { piAcp: { queueDepth: this.turnQueue.length, running: true } }
550
+ })
551
+
552
+ // Kick off pi, but completion is determined by pi events, not the RPC response.
553
+ // The prompt RPC only acknowledges acceptance; retry, compaction, or queued
554
+ // continuations may emit multiple `agent_end` events before `agent_settled`.
555
+ this.proc.prompt(t.message, t.images).catch(err => {
556
+ // If the subprocess errors before we get `agent_settled`, treat as error unless cancelled.
557
+ // Also ensure we flush any already-enqueued updates first.
558
+ void this.flushEmits().finally(() => {
559
+ // If this looks like an auth/config issue, surface AUTH_REQUIRED so clients can offer terminal login.
560
+ const authErr = maybeAuthRequiredError(err)
561
+ if (authErr) {
562
+ this.pendingTurn?.reject(authErr)
563
+ } else {
564
+ const reason: StopReason = this.cancelRequested ? 'cancelled' : 'error'
565
+ this.pendingTurn?.resolve(reason)
566
+ }
567
+
568
+ this.pendingTurn = null
569
+ this.inAgentLoop = false
570
+
571
+ // If the prompt failed, do not automatically proceed—pi may be unhealthy.
572
+ // But we still clear the queueDepth metadata.
573
+ this.emit({
574
+ sessionUpdate: 'session_info_update',
575
+ _meta: { piAcp: { queueDepth: this.turnQueue.length, running: false } }
576
+ })
577
+ })
578
+ void err
579
+ })
580
+ }
581
+
582
+ private handlePiEvent(ev: PiRpcEvent) {
583
+ const type = String((ev as any).type ?? '')
584
+
585
+ switch (type) {
586
+ case 'message_update': {
587
+ const ame = (ev as any).assistantMessageEvent
588
+
589
+ // Stream assistant text.
590
+ if (ame?.type === 'text_delta' && typeof ame.delta === 'string') {
591
+ this.emit({
592
+ sessionUpdate: 'agent_message_chunk',
593
+ content: { type: 'text', text: ame.delta } satisfies ContentBlock
594
+ })
595
+ break
596
+ }
597
+
598
+ if (ame?.type === 'thinking_delta' && typeof ame.delta === 'string') {
599
+ this.emit({
600
+ sessionUpdate: 'agent_thought_chunk',
601
+ content: { type: 'text', text: ame.delta } satisfies ContentBlock
602
+ })
603
+ break
604
+ }
605
+
606
+ // Surface tool calls ASAP so clients (e.g. Zed) can show a tool-in-use/loading UI
607
+ // while the model is still streaming tool call args.
608
+ if (ame?.type === 'toolcall_start' || ame?.type === 'toolcall_delta' || ame?.type === 'toolcall_end') {
609
+ const toolCall =
610
+ // pi sometimes includes the tool call directly on the event
611
+ (ame as any)?.toolCall ??
612
+ // ...and always includes it in the partial assistant message at contentIndex
613
+ (ame as any)?.partial?.content?.[(ame as any)?.contentIndex ?? 0]
614
+
615
+ const toolCallId = String((toolCall as any)?.id ?? '')
616
+ const toolName = String((toolCall as any)?.name ?? 'tool')
617
+
618
+ if (toolCallId) {
619
+ const rawInput =
620
+ (toolCall as any)?.arguments && typeof (toolCall as any).arguments === 'object'
621
+ ? (toolCall as any).arguments
622
+ : (() => {
623
+ const s = String((toolCall as any)?.partialArgs ?? '')
624
+ if (!s) return undefined
625
+ try {
626
+ return JSON.parse(s)
627
+ } catch {
628
+ return { partialArgs: s }
629
+ }
630
+ })()
631
+
632
+ const locations = toToolCallLocations(rawInput, this.cwd)
633
+ const existingStatus = this.currentToolCalls.get(toolCallId)
634
+ // IMPORTANT: never downgrade status (e.g. if we already marked in_progress via tool_execution_start).
635
+ const status = existingStatus ?? 'pending'
636
+
637
+ if (isBashTool(toolName)) {
638
+ if (!existingStatus) this.currentToolCalls.set(toolCallId, 'pending')
639
+ this.emitBashToolCall({
640
+ sessionUpdate: existingStatus ? 'tool_call_update' : 'tool_call',
641
+ toolCallId,
642
+ toolName,
643
+ args: rawInput,
644
+ status,
645
+ locations,
646
+ includeTerminal: !existingStatus
647
+ })
648
+ } else if (!existingStatus) {
649
+ this.currentToolCalls.set(toolCallId, 'pending')
650
+ this.emit({
651
+ sessionUpdate: 'tool_call',
652
+ toolCallId,
653
+ title: toolName,
654
+ kind: toToolKind(toolName),
655
+ status,
656
+ locations,
657
+ rawInput
658
+ })
659
+ } else {
660
+ // Best-effort: keep rawInput updated while args are streaming.
661
+ // Keep the existing status (pending or in_progress).
662
+ this.emit({
663
+ sessionUpdate: 'tool_call_update',
664
+ toolCallId,
665
+ status,
666
+ locations,
667
+ rawInput
668
+ })
669
+ }
670
+ }
671
+
672
+ break
673
+ }
674
+
675
+ // Ignore other delta/event types for now.
676
+ break
677
+ }
678
+
679
+ case 'tool_execution_start': {
680
+ const toolCallId = String((ev as any).toolCallId ?? crypto.randomUUID())
681
+ const toolName = String((ev as any).toolName ?? 'tool')
682
+ const args = (ev as any).args
683
+ let line: number | undefined
684
+
685
+ if (isBashTool(toolName)) {
686
+ const locations = toToolCallLocations(args, this.cwd)
687
+ const existingStatus = this.currentToolCalls.get(toolCallId)
688
+ this.currentToolCalls.set(toolCallId, 'in_progress')
689
+ this.emitBashToolCall({
690
+ sessionUpdate: existingStatus ? 'tool_call_update' : 'tool_call',
691
+ toolCallId,
692
+ toolName,
693
+ args,
694
+ status: 'in_progress',
695
+ locations,
696
+ includeTerminal: !existingStatus
697
+ })
698
+ break
699
+ }
700
+
701
+ // Capture pre-mutation file contents so we can emit a structured ACP diff.
702
+ const isFileMutation = toolName === 'edit' || toolName === 'write'
703
+ let snapshotOldText: string | null | undefined
704
+ if (isFileMutation) {
705
+ this.fileMutationToolCallIds.add(toolCallId)
706
+ const p = getToolPath(args)
707
+ if (p) {
708
+ try {
709
+ const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p)
710
+ snapshotOldText = readFileSync(abs, 'utf8')
711
+ this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText })
712
+
713
+ if (toolName === 'edit') {
714
+ for (const needle of getEditOldTexts(args)) {
715
+ line = findUniqueLineNumber(snapshotOldText, needle)
716
+ if (typeof line === 'number') break
717
+ }
718
+ }
719
+ } catch {
720
+ snapshotOldText = null
721
+ this.fileSnapshots.set(toolCallId, { path: p, oldText: null })
722
+ }
723
+ }
724
+ }
725
+
726
+ const locations = toToolCallLocations(args, this.cwd, line)
727
+
728
+ // If we already surfaced the tool call while the model streamed it, just transition.
729
+ if (!this.currentToolCalls.has(toolCallId)) {
730
+ this.currentToolCalls.set(toolCallId, 'in_progress')
731
+ this.emit({
732
+ sessionUpdate: 'tool_call',
733
+ toolCallId,
734
+ title: toolName,
735
+ kind: toToolKind(toolName),
736
+ status: 'in_progress',
737
+ locations,
738
+ rawInput: args
739
+ })
740
+ } else {
741
+ this.currentToolCalls.set(toolCallId, 'in_progress')
742
+ this.emit({
743
+ sessionUpdate: 'tool_call_update',
744
+ toolCallId,
745
+ status: 'in_progress',
746
+ locations,
747
+ rawInput: args
748
+ })
749
+ }
750
+
751
+ break
752
+ }
753
+
754
+ case 'tool_execution_update': {
755
+ const toolCallId = String((ev as any).toolCallId ?? '')
756
+ if (!toolCallId) break
757
+
758
+ const partial = (ev as any).partialResult
759
+ if (this.bashToolCallIds.has(toolCallId)) {
760
+ this.emitBashOutputUpdate({ toolCallId, status: 'in_progress', result: partial })
761
+ break
762
+ }
763
+
764
+ const text = this.fileMutationToolCallIds.has(toolCallId) ? '' : toolResultToText(partial)
765
+
766
+ this.emit({
767
+ sessionUpdate: 'tool_call_update',
768
+ toolCallId,
769
+ status: 'in_progress',
770
+ content: text
771
+ ? ([{ type: 'content', content: { type: 'text', text } }] satisfies ToolCallContent[])
772
+ : undefined,
773
+ ...(this.fileMutationToolCallIds.has(toolCallId) ? {} : { rawOutput: partial })
774
+ })
775
+ break
776
+ }
777
+
778
+ case 'tool_execution_end': {
779
+ const toolCallId = String((ev as any).toolCallId ?? '')
780
+ if (!toolCallId) break
781
+
782
+ const result = (ev as any).result
783
+ const isError = Boolean((ev as any).isError)
784
+ if (this.bashToolCallIds.has(toolCallId)) {
785
+ this.emitBashOutputUpdate({
786
+ toolCallId,
787
+ status: isError ? 'failed' : 'completed',
788
+ result,
789
+ isError
790
+ })
791
+ this.cleanupToolCall(toolCallId)
792
+ break
793
+ }
794
+
795
+ const text = toolResultToText(result)
796
+
797
+ const snapshot = this.fileSnapshots.get(toolCallId)
798
+ let content: ToolCallContent[] | undefined
799
+ let hasStructuredDiff = false
800
+
801
+ if (!isError && snapshot) {
802
+ try {
803
+ const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path)
804
+ const newText = readFileSync(abs, 'utf8')
805
+ if (snapshot.oldText === null || newText !== snapshot.oldText) {
806
+ hasStructuredDiff = true
807
+ content = [
808
+ {
809
+ type: 'diff',
810
+ path: snapshot.path,
811
+ oldText: snapshot.oldText,
812
+ newText
813
+ }
814
+ ]
815
+ }
816
+ } catch {
817
+ // ignore; fall back to text only
818
+ }
819
+ }
820
+
821
+ if (!content && !hasStructuredDiff && text) {
822
+ content = [{ type: 'content', content: { type: 'text', text } }] satisfies ToolCallContent[]
823
+ }
824
+
825
+ this.emit({
826
+ sessionUpdate: 'tool_call_update',
827
+ toolCallId,
828
+ status: isError ? 'failed' : 'completed',
829
+ content,
830
+ ...(hasStructuredDiff ? {} : { rawOutput: result })
831
+ })
832
+
833
+ this.cleanupToolCall(toolCallId)
834
+ break
835
+ }
836
+
837
+ case 'extension_ui_request': {
838
+ void this.handleExtensionUiRequest(ev).catch(() => {
839
+ const id = stringProp(ev, 'id')
840
+ if (!id) {
841
+ return
842
+ }
843
+
844
+ void this.proc.sendExtensionUiResponse({ id, cancelled: true }).catch(() => {})
845
+ })
846
+ break
847
+ }
848
+
849
+ case 'auto_retry_start': {
850
+ this.emit({
851
+ sessionUpdate: 'agent_message_chunk',
852
+ content: { type: 'text', text: formatAutoRetryMessage(ev) } satisfies ContentBlock
853
+ })
854
+ break
855
+ }
856
+
857
+ case 'auto_retry_end': {
858
+ this.emit({
859
+ sessionUpdate: 'agent_message_chunk',
860
+ content: { type: 'text', text: 'Retry finished, resuming.' } satisfies ContentBlock
861
+ })
862
+ break
863
+ }
864
+
865
+ case 'auto_compaction_start': {
866
+ this.emit({
867
+ sessionUpdate: 'agent_message_chunk',
868
+ content: {
869
+ type: 'text',
870
+ text: 'Context nearing limit, running automatic compaction...'
871
+ } satisfies ContentBlock
872
+ })
873
+ break
874
+ }
875
+
876
+ case 'auto_compaction_end': {
877
+ this.emit({
878
+ sessionUpdate: 'agent_message_chunk',
879
+ content: {
880
+ type: 'text',
881
+ text: 'Automatic compaction finished; context was summarized to continue the session.'
882
+ } satisfies ContentBlock
883
+ })
884
+ break
885
+ }
886
+
887
+ case 'agent_start': {
888
+ this.inAgentLoop = true
889
+ break
890
+ }
891
+
892
+ case 'turn_end': {
893
+ // pi uses `turn_end` for sub-steps (e.g. tool_use) and will often start another turn.
894
+ // Do NOT resolve the ACP `session/prompt` here; wait for `agent_settled`.
895
+ break
896
+ }
897
+
898
+ case 'agent_end': {
899
+ // One low-level run ended. Pi may still retry, compact, or process a queued
900
+ // continuation, so keep the ACP turn open until `agent_settled`.
901
+ this.inAgentLoop = false
902
+ break
903
+ }
904
+
905
+ case 'agent_settled': {
906
+ void this.settleTurn()
907
+ break
908
+ }
909
+
910
+ default:
911
+ break
912
+ }
913
+ }
914
+
915
+ private async handleExtensionUiRequest(ev: PiRpcEvent): Promise<void> {
916
+ const id = stringProp(ev, 'id')
917
+ const method = stringProp(ev, 'method')
918
+ if (!id) {
919
+ return
920
+ }
921
+
922
+ if (method === 'select') {
923
+ await this.handleExtensionSelect(ev, id)
924
+ return
925
+ }
926
+
927
+ if (method === 'confirm') {
928
+ await this.handleExtensionConfirm(ev, id)
929
+ return
930
+ }
931
+
932
+ if (method === 'input' || method === 'editor') {
933
+ this.emit({
934
+ sessionUpdate: 'agent_message_chunk',
935
+ content: {
936
+ type: 'text',
937
+ text: `Pi ${method} UI request is not supported in ACP yet; cancelling it.`
938
+ } satisfies ContentBlock
939
+ })
940
+ await this.proc.sendExtensionUiResponse({ id, cancelled: true })
941
+ return
942
+ }
943
+
944
+ if (method === 'notify') {
945
+ this.emit({
946
+ sessionUpdate: 'agent_message_chunk',
947
+ content: { type: 'text', text: stringProp(ev, 'message') ?? 'Pi notification' } satisfies ContentBlock,
948
+ _meta: { piAcp: { notify: { level: stringProp(ev, 'notifyType') ?? 'info' } } }
949
+ })
950
+ await this.proc.sendExtensionUiResponse({ id, cancelled: true })
951
+ return
952
+ }
953
+
954
+ await this.proc.sendExtensionUiResponse({ id, cancelled: true })
955
+ }
956
+
957
+ private async handleExtensionSelect(ev: PiRpcEvent, id: string): Promise<void> {
958
+ const rawOptions = ev.options
959
+ const options = Array.isArray(rawOptions) ? rawOptions.map(option => String(option)) : []
960
+ if (!options.length) {
961
+ await this.proc.sendExtensionUiResponse({ id, cancelled: true })
962
+ return
963
+ }
964
+
965
+ const permissionOptions: PermissionOption[] = options.map((name, index) => ({
966
+ optionId: `${CHOICE_OPTION_PREFIX}${index}`,
967
+ name,
968
+ kind: 'allow_once'
969
+ }))
970
+
971
+ const selected = await this.requestExtensionPermission(id, ev, permissionOptions)
972
+ if (selected === null) {
973
+ return
974
+ }
975
+
976
+ const selectedOptionId = selected.outcome.outcome === 'selected' ? selected.outcome.optionId : null
977
+ const index = selectedOptionId === null ? null : optionIndex(selectedOptionId)
978
+ const value = index === null ? null : (options.at(index) ?? null)
979
+ await this.proc.sendExtensionUiResponse(value === null ? { id, cancelled: true } : { id, value })
980
+ }
981
+
982
+ private async handleExtensionConfirm(ev: PiRpcEvent, id: string): Promise<void> {
983
+ const selected = await this.requestExtensionPermission(id, ev, CONFIRM_PERMISSION_OPTIONS)
984
+ if (selected === null) {
985
+ return
986
+ }
987
+
988
+ if (selected.outcome.outcome === 'cancelled') {
989
+ await this.proc.sendExtensionUiResponse({ id, cancelled: true })
990
+ return
991
+ }
992
+
993
+ await this.proc.sendExtensionUiResponse({ id, confirmed: selected.outcome.optionId === 'yes' })
994
+ }
995
+
996
+ private async requestExtensionPermission(
997
+ id: string,
998
+ ev: PiRpcEvent,
999
+ options: PermissionOption[]
1000
+ ): Promise<PermissionResponse | null> {
1001
+ try {
1002
+ return await this.conn.requestPermission({
1003
+ sessionId: this.sessionId,
1004
+ toolCall: extensionUiToolCall(id, ev),
1005
+ options
1006
+ })
1007
+ } catch {
1008
+ await this.proc.sendExtensionUiResponse({ id, cancelled: true })
1009
+ return null
1010
+ }
1011
+ }
1012
+ }
1013
+
1014
+ function extensionUiToolCall(id: string, ev: PiRpcEvent) {
1015
+ const method = stringProp(ev, 'method') ?? 'ui'
1016
+ const title = stringProp(ev, 'title') ?? `Pi ${method}`
1017
+ const rawInput: Record<string, unknown> = { method }
1018
+
1019
+ for (const key of EXTENSION_UI_RAW_INPUT_KEYS) {
1020
+ if (Object.hasOwn(ev, key)) rawInput[key] = ev[key]
1021
+ }
1022
+
1023
+ return {
1024
+ toolCallId: `pi-ui-${id}`,
1025
+ title,
1026
+ kind: 'other' as const,
1027
+ status: 'pending' as const,
1028
+ rawInput
1029
+ }
1030
+ }
1031
+
1032
+ function stringProp(source: Record<string, unknown>, key: string): string | null {
1033
+ const value = source[key]
1034
+ return typeof value === 'string' ? value : null
1035
+ }
1036
+
1037
+ function optionIndex(optionId: string): number | null {
1038
+ if (!optionId.startsWith(CHOICE_OPTION_PREFIX)) {
1039
+ return null
1040
+ }
1041
+
1042
+ const rawIndex = optionId.slice(CHOICE_OPTION_PREFIX.length)
1043
+ if (!rawIndex) {
1044
+ return null
1045
+ }
1046
+
1047
+ const index = Number(rawIndex)
1048
+ return Number.isSafeInteger(index) && index >= 0 && String(index) === rawIndex ? index : null
1049
+ }
1050
+
1051
+ function formatAutoRetryMessage(ev: PiRpcEvent): string {
1052
+ const attempt = Number((ev as any).attempt)
1053
+ const maxAttempts = Number((ev as any).maxAttempts)
1054
+ const delayMs = Number((ev as any).delayMs)
1055
+
1056
+ if (!Number.isFinite(attempt) || !Number.isFinite(maxAttempts) || !Number.isFinite(delayMs)) {
1057
+ return 'Retrying...'
1058
+ }
1059
+
1060
+ let delaySeconds = Math.round(delayMs / 1000)
1061
+ if (delayMs > 0 && delaySeconds === 0) delaySeconds = 1
1062
+
1063
+ return `Retrying (attempt ${attempt}/${maxAttempts}, waiting ${delaySeconds}s)...`
1064
+ }
1065
+
1066
+ function toToolKind(toolName: string): ToolKind {
1067
+ switch (toolName) {
1068
+ case 'read':
1069
+ return 'read'
1070
+ case 'write':
1071
+ case 'edit':
1072
+ return 'edit'
1073
+ case 'bash':
1074
+ return 'execute'
1075
+ default:
1076
+ return 'other'
1077
+ }
1078
+ }