@pi-in-go/pigpen-acp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +23 -0
- package/LICENSE +22 -0
- package/README.md +110 -0
- package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
- package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
- package/extensions/acp/cmd/pig-acp/go.mod +6 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/main.go +177 -0
- package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
- package/extensions/acp/extension.go +63 -0
- package/extensions/acp/extension_test.go +111 -0
- package/extensions/acp/fakehost_test.go +548 -0
- package/extensions/acp/go.mod +6 -0
- package/extensions/acp/go.sum +2 -0
- package/package.json +41 -0
- package/port/PORT.md +147 -0
- package/port/interop/package-lock.json +89 -0
- package/port/interop/package.json +13 -0
- package/port/interop/run.mjs +229 -0
- package/port/mutate.mjs +110 -0
- package/port/mutation-results.txt +107 -0
- package/port/mutations.json +748 -0
- package/port/oracle/pi-acp/LICENSE +21 -0
- package/port/oracle/pi-acp/README.md +209 -0
- package/port/oracle/pi-acp/package.json +65 -0
- package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
- package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
- package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
- package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
- package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
- package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
- package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
- package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
- package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
- package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
- package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
- package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
- package/port/oracle/pi-acp/src/index.ts +82 -0
- package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
- package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
- package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
- package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
- package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
- package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
- package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
- package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
- package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
- package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
- package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
- package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
- package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
- package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
- package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
- package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
- package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
- package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
- package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
- package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
- package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
- package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
- package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
- package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
- package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
- package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
- package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
- package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
- package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
- package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
- package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
- package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
- package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
- package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
- package/port/schema/LICENSE +191 -0
- package/port/schema/schema.json +8015 -0
- package/port/upstream-tests.json +201 -0
- package/provenance.json +28 -0
|
@@ -0,0 +1,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
|
+
}
|