@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,432 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
2
+ import * as readline from 'node:readline'
3
+ import crossSpawn from 'cross-spawn'
4
+ import { getPiCommand, shouldUseShellForPiCommand } from './command.js'
5
+
6
+ export class PiRpcSpawnError extends Error {
7
+ /** Underlying spawn error code, e.g. ENOENT, EACCES */
8
+ code?: string
9
+
10
+ constructor(message: string, opts?: { code?: string; cause?: unknown }) {
11
+ super(message)
12
+ this.name = 'PiRpcSpawnError'
13
+ this.code = opts?.code
14
+ ;(this as any).cause = opts?.cause
15
+ }
16
+ }
17
+
18
+ const ESC = String.fromCharCode(0x1b)
19
+ const CSI = String.fromCharCode(0x9b)
20
+
21
+ const ANSI_ESCAPE_REGEX = new RegExp(
22
+ `[${ESC}${CSI}][[\\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`,
23
+ 'g'
24
+ )
25
+
26
+ function stripAnsi(s: string): string {
27
+ // Basic ANSI escape stripping (colors, cursor movement, etc.)
28
+ return s.replace(ANSI_ESCAPE_REGEX, '')
29
+ }
30
+
31
+ type PiRpcCommand =
32
+ | { type: 'prompt'; id?: string; message: string; images?: unknown[] }
33
+ | { type: 'abort'; id?: string }
34
+ | { type: 'get_state'; id?: string }
35
+ // Model
36
+ | { type: 'get_available_models'; id?: string }
37
+ | { type: 'set_model'; id?: string; provider: string; modelId: string }
38
+ // Thinking
39
+ | { type: 'get_available_thinking_levels'; id?: string }
40
+ | { type: 'set_thinking_level'; id?: string; level: string }
41
+ // Modes
42
+ | { type: 'set_follow_up_mode'; id?: string; mode: 'all' | 'one-at-a-time' }
43
+ | { type: 'set_steering_mode'; id?: string; mode: 'all' | 'one-at-a-time' }
44
+ // Compaction
45
+ | { type: 'compact'; id?: string; customInstructions?: string }
46
+ | { type: 'set_auto_compaction'; id?: string; enabled: boolean }
47
+ // Session
48
+ | { type: 'get_session_stats'; id?: string }
49
+ | { type: 'set_session_name'; id?: string; name: string }
50
+ | { type: 'export_html'; id?: string; outputPath?: string }
51
+ | { type: 'switch_session'; id?: string; sessionPath: string }
52
+ // Messages
53
+ | { type: 'get_messages'; id?: string }
54
+ // Commands
55
+ | { type: 'get_commands'; id?: string }
56
+
57
+ type PiRpcResponse = {
58
+ type: 'response'
59
+ id?: string
60
+ command: string
61
+ success: boolean
62
+ data?: unknown
63
+ error?: string
64
+ }
65
+
66
+ type PiExtensionUiResponse =
67
+ | { id: string; value: string }
68
+ | { id: string; confirmed: boolean }
69
+ | { id: string; cancelled: true }
70
+
71
+ export type PiRpcEvent = Record<string, unknown>
72
+
73
+ /** Maximum wait for an auxiliary context-usage update. */
74
+ export const SESSION_STATS_TIMEOUT_MS = 1_000
75
+
76
+ /**
77
+ * Shape of `stats.contextUsage` in pi's `get_session_stats` response.
78
+ * `tokens` is null while pi has no trustworthy token count (e.g. right after compaction).
79
+ */
80
+ export type PiContextUsage = {
81
+ tokens?: number | null
82
+ contextWindow?: number | null
83
+ }
84
+
85
+ export type PiSessionStats = {
86
+ sessionId?: string
87
+ sessionFile?: string
88
+ totalMessages?: number
89
+ cost?: number
90
+ tokens?: {
91
+ input?: number
92
+ output?: number
93
+ cacheRead?: number
94
+ cacheWrite?: number
95
+ total?: number
96
+ }
97
+ contextUsage?: PiContextUsage | null
98
+ }
99
+
100
+ type SpawnParams = {
101
+ cwd: string
102
+ /** Optional override for `pi` executable name/path */
103
+ piCommand?: string
104
+ /** If set, pi will persist the session to this exact file (via `--session <path>`). */
105
+ sessionPath?: string
106
+ }
107
+
108
+ export class PiRpcProcess {
109
+ private readonly child: ChildProcessWithoutNullStreams
110
+ private readonly pending = new Map<string, { resolve: (v: PiRpcResponse) => void; reject: (e: unknown) => void }>()
111
+ private eventHandlers: Array<(ev: PiRpcEvent) => void> = []
112
+ private readonly preludeLines: string[] = []
113
+
114
+ private constructor(child: ChildProcessWithoutNullStreams) {
115
+ this.child = child
116
+
117
+ const rl = readline.createInterface({ input: child.stdout })
118
+ rl.on('line', line => {
119
+ if (!line.trim()) return
120
+ let msg: any
121
+ try {
122
+ msg = JSON.parse(line)
123
+ } catch {
124
+ // pi may emit a human-readable prelude on stdout before NDJSON starts.
125
+ // Capture it so the ACP adapter can surface it on session start.
126
+ const cleaned = stripAnsi(String(line)).trimEnd()
127
+ if (cleaned) this.preludeLines.push(cleaned)
128
+ return
129
+ }
130
+
131
+ if (msg?.type === 'response') {
132
+ const id = typeof msg.id === 'string' ? msg.id : undefined
133
+ // `resolve` removes the pending entry. Responses for unknown or already timed-out
134
+ // ids are dropped: a response is never a pi event, so it must not be broadcast.
135
+ if (id !== undefined) this.pending.get(id)?.resolve(msg as PiRpcResponse)
136
+ return
137
+ }
138
+
139
+ for (const h of this.eventHandlers) h(msg as PiRpcEvent)
140
+ })
141
+
142
+ child.on('exit', (code, signal) => {
143
+ const err = new Error(`pi process exited (code=${code}, signal=${signal})`)
144
+ for (const [, p] of this.pending) p.reject(err)
145
+ this.pending.clear()
146
+ })
147
+
148
+ child.on('error', err => {
149
+ for (const [, p] of this.pending) p.reject(err)
150
+ this.pending.clear()
151
+ })
152
+ }
153
+
154
+ static async spawn(params: SpawnParams): Promise<PiRpcProcess> {
155
+ // On Windows, npm commonly creates pi.cmd / pi.bat launcher scripts.
156
+ const cmd = getPiCommand(params.piCommand)
157
+
158
+ // Speed/robustness for ACP:
159
+ // - themes are irrelevant in rpc mode and can be noisy/slow to load.
160
+ // Keep extensions + prompt templates enabled because ACP users may rely on them
161
+ // (e.g. MCP extensions, prompt templates for workflows).
162
+ const args = ['--mode', 'rpc', '--no-themes']
163
+ if (params.sessionPath) args.push('--session', params.sessionPath)
164
+
165
+ // Windows cmd launchers need shell escaping; direct executables use native argv.
166
+ const start = shouldUseShellForPiCommand(cmd) ? crossSpawn : spawn
167
+ const child = start(cmd, args, {
168
+ cwd: params.cwd,
169
+ stdio: 'pipe',
170
+ env: process.env
171
+ }) as ChildProcessWithoutNullStreams
172
+
173
+ // Ensure spawn failures (e.g. ENOENT when pi isn't installed) are surfaced as a
174
+ // deterministic error instead of later EPIPE/internal-error noise.
175
+ try {
176
+ await new Promise<void>((resolve, reject) => {
177
+ const onSpawn = () => {
178
+ cleanup()
179
+ resolve()
180
+ }
181
+ const onError = (err: any) => {
182
+ cleanup()
183
+ reject(err)
184
+ }
185
+ const cleanup = () => {
186
+ child.off('spawn', onSpawn)
187
+ child.off('error', onError)
188
+ }
189
+
190
+ child.once('spawn', onSpawn)
191
+ child.once('error', onError)
192
+ })
193
+ } catch (e: any) {
194
+ const code = typeof e?.code === 'string' ? e.code : undefined
195
+ if (code === 'ENOENT') {
196
+ throw new PiRpcSpawnError(
197
+ `Could not start pi: executable not found (command: ${cmd}). Pi needs to be installed before it can run in ACP clients. Install it via \`npm install -g @earendil-works/pi-coding-agent\` or ensure \`pi\` is on your PATH. Then try again.`,
198
+ { code, cause: e }
199
+ )
200
+ }
201
+
202
+ if (code === 'EACCES') {
203
+ throw new PiRpcSpawnError(`Could not start pi: permission denied (command: ${cmd}).`, { code, cause: e })
204
+ }
205
+
206
+ throw new PiRpcSpawnError(`Could not start pi (command: ${cmd}).`, { code, cause: e })
207
+ }
208
+
209
+ child.stderr.on('data', () => {
210
+ // leave stderr untouched; ACP clients may capture it.
211
+ })
212
+
213
+ const proc = new PiRpcProcess(child)
214
+
215
+ // Best-effort handshake.
216
+ // Important: pi may emit a get_state response pointing at a sessionFile in a directory
217
+ // that is created lazily. Create the parent dir up-front to avoid later parse errors
218
+ // when we call commands like export_html.
219
+ try {
220
+ const state = (await proc.getState()) as any
221
+ const sessionFile = typeof state?.sessionFile === 'string' ? state.sessionFile : null
222
+ if (sessionFile) {
223
+ const { mkdirSync } = await import('node:fs')
224
+ const { dirname } = await import('node:path')
225
+ mkdirSync(dirname(sessionFile), { recursive: true })
226
+ }
227
+ } catch {
228
+ // ignore for now
229
+ }
230
+
231
+ return proc
232
+ }
233
+
234
+ onEvent(handler: (ev: PiRpcEvent) => void): () => void {
235
+ this.eventHandlers.push(handler)
236
+ return () => {
237
+ this.eventHandlers = this.eventHandlers.filter(h => h !== handler)
238
+ }
239
+ }
240
+
241
+ dispose(signal: NodeJS.Signals | number = 'SIGTERM'): void {
242
+ if (this.child.killed) return
243
+ try {
244
+ this.child.kill(signal as any)
245
+ } catch {
246
+ // ignore
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Human-readable stdout lines emitted before RPC NDJSON begins (e.g. Context/Skills/Extensions info).
252
+ * Themes are typically noisy/less useful for ACP, so callers can filter as needed.
253
+ */
254
+ consumePreludeLines(): string[] {
255
+ const lines = this.preludeLines.splice(0, this.preludeLines.length)
256
+ return lines
257
+ }
258
+
259
+ async prompt(message: string, images: unknown[] = []): Promise<void> {
260
+ const res = await this.request({ type: 'prompt', message, images })
261
+ if (!res.success) throw new Error(`pi prompt failed: ${res.error ?? JSON.stringify(res.data)}`)
262
+ }
263
+
264
+ async abort(): Promise<void> {
265
+ const res = await this.request({ type: 'abort' })
266
+ if (!res.success) throw new Error(`pi abort failed: ${res.error ?? JSON.stringify(res.data)}`)
267
+ }
268
+
269
+ async getState(): Promise<unknown> {
270
+ const res = await this.request({ type: 'get_state' })
271
+ if (!res.success) throw new Error(`pi get_state failed: ${res.error ?? JSON.stringify(res.data)}`)
272
+ return res.data
273
+ }
274
+
275
+ async getAvailableModels(): Promise<unknown> {
276
+ const res = await this.request({ type: 'get_available_models' })
277
+ if (!res.success) throw new Error(`pi get_available_models failed: ${res.error ?? JSON.stringify(res.data)}`)
278
+ return res.data
279
+ }
280
+
281
+ async setModel(provider: string, modelId: string): Promise<unknown> {
282
+ const res = await this.request({ type: 'set_model', provider, modelId })
283
+ if (!res.success) throw new Error(`pi set_model failed: ${res.error ?? JSON.stringify(res.data)}`)
284
+ return res.data
285
+ }
286
+
287
+ async getAvailableThinkingLevels(): Promise<string[]> {
288
+ const res = await this.request({ type: 'get_available_thinking_levels' })
289
+ if (!res.success)
290
+ throw new Error(`pi get_available_thinking_levels failed: ${res.error ?? JSON.stringify(res.data)}`)
291
+ const data = res.data
292
+ const levels = data && typeof data === 'object' && 'levels' in data ? data.levels : undefined
293
+ if (
294
+ !Array.isArray(levels) ||
295
+ levels.length === 0 ||
296
+ !levels.every(level => typeof level === 'string' && level.length > 0)
297
+ ) {
298
+ throw new Error('pi get_available_thinking_levels returned invalid levels')
299
+ }
300
+ return levels
301
+ }
302
+
303
+ async setThinkingLevel(level: string): Promise<void> {
304
+ const res = await this.request({ type: 'set_thinking_level', level })
305
+ if (!res.success) throw new Error(`pi set_thinking_level failed: ${res.error ?? JSON.stringify(res.data)}`)
306
+ }
307
+
308
+ async setFollowUpMode(mode: 'all' | 'one-at-a-time'): Promise<void> {
309
+ const res = await this.request({ type: 'set_follow_up_mode', mode })
310
+ if (!res.success) throw new Error(`pi set_follow_up_mode failed: ${res.error ?? JSON.stringify(res.data)}`)
311
+ }
312
+
313
+ async setSteeringMode(mode: 'all' | 'one-at-a-time'): Promise<void> {
314
+ const res = await this.request({ type: 'set_steering_mode', mode })
315
+ if (!res.success) throw new Error(`pi set_steering_mode failed: ${res.error ?? JSON.stringify(res.data)}`)
316
+ }
317
+
318
+ async compact(customInstructions?: string): Promise<unknown> {
319
+ const res = await this.request({ type: 'compact', customInstructions })
320
+ if (!res.success) throw new Error(`pi compact failed: ${res.error ?? JSON.stringify(res.data)}`)
321
+ return res.data
322
+ }
323
+
324
+ async setAutoCompaction(enabled: boolean): Promise<void> {
325
+ const res = await this.request({ type: 'set_auto_compaction', enabled })
326
+ if (!res.success) throw new Error(`pi set_auto_compaction failed: ${res.error ?? JSON.stringify(res.data)}`)
327
+ }
328
+
329
+ async getSessionStats(timeoutMs?: number): Promise<PiSessionStats> {
330
+ const res = await this.request({ type: 'get_session_stats' }, { timeoutMs })
331
+ if (!res.success) throw new Error(`pi get_session_stats failed: ${res.error ?? JSON.stringify(res.data)}`)
332
+ return (res.data ?? {}) as PiSessionStats
333
+ }
334
+
335
+ async setSessionName(name: string): Promise<void> {
336
+ const res = await this.request({ type: 'set_session_name', name })
337
+ if (!res.success) throw new Error(`pi set_session_name failed: ${res.error ?? JSON.stringify(res.data)}`)
338
+ }
339
+
340
+ async exportHtml(outputPath?: string): Promise<{ path: string }> {
341
+ const res = await this.request({ type: 'export_html', outputPath })
342
+ if (!res.success) throw new Error(`pi export_html failed: ${res.error ?? JSON.stringify(res.data)}`)
343
+ const data: any = res.data
344
+ return { path: String(data?.path ?? '') }
345
+ }
346
+
347
+ async switchSession(sessionPath: string): Promise<void> {
348
+ const res = await this.request({ type: 'switch_session', sessionPath })
349
+ if (!res.success) throw new Error(`pi switch_session failed: ${res.error ?? JSON.stringify(res.data)}`)
350
+ }
351
+
352
+ async getMessages(): Promise<unknown> {
353
+ const res = await this.request({ type: 'get_messages' })
354
+ if (!res.success) throw new Error(`pi get_messages failed: ${res.error ?? JSON.stringify(res.data)}`)
355
+ return res.data
356
+ }
357
+
358
+ async getCommands(): Promise<unknown> {
359
+ const res = await this.request({ type: 'get_commands' })
360
+ if (!res.success) throw new Error(`pi get_commands failed: ${res.error ?? JSON.stringify(res.data)}`)
361
+ return res.data
362
+ }
363
+
364
+ async sendExtensionUiResponse(response: PiExtensionUiResponse): Promise<void> {
365
+ await this.writeLine(`${JSON.stringify({ type: 'extension_ui_response', ...response })}\n`)
366
+ }
367
+
368
+ private request(cmd: PiRpcCommand, opts?: { timeoutMs?: number }): Promise<PiRpcResponse> {
369
+ const id = crypto.randomUUID()
370
+ const withId = { ...cmd, id }
371
+ const timeoutMs = opts?.timeoutMs
372
+
373
+ const line = `${JSON.stringify(withId)}\n`
374
+
375
+ return new Promise<PiRpcResponse>((resolve, reject) => {
376
+ let timer: ReturnType<typeof setTimeout> | undefined
377
+
378
+ // Returns false when the id was already dropped (e.g. by the timeout), so the
379
+ // caller can avoid settling the promise twice.
380
+ const drop = (): boolean => {
381
+ if (timer !== undefined) {
382
+ clearTimeout(timer)
383
+ timer = undefined
384
+ }
385
+ return this.pending.delete(id)
386
+ }
387
+
388
+ this.pending.set(id, {
389
+ resolve: res => {
390
+ drop()
391
+ resolve(res)
392
+ },
393
+ reject: error => {
394
+ drop()
395
+ reject(error)
396
+ }
397
+ })
398
+
399
+ if (timeoutMs !== undefined) {
400
+ timer = setTimeout(() => {
401
+ timer = undefined
402
+ if (!this.pending.delete(id)) return
403
+ reject(new Error(`pi ${cmd.type} timed out after ${timeoutMs}ms`))
404
+ }, timeoutMs)
405
+ // Never let an auxiliary request keep the event loop alive.
406
+ timer.unref?.()
407
+ }
408
+
409
+ void this.writeLine(line).catch(error => {
410
+ if (!drop()) return
411
+ reject(error)
412
+ })
413
+ })
414
+ }
415
+
416
+ private writeLine(line: string): Promise<void> {
417
+ return new Promise<void>((resolve, reject) => {
418
+ try {
419
+ this.child.stdin.write(line, error => {
420
+ if (error) {
421
+ reject(error)
422
+ return
423
+ }
424
+
425
+ resolve()
426
+ })
427
+ } catch (error: unknown) {
428
+ reject(error)
429
+ }
430
+ })
431
+ }
432
+ }
@@ -0,0 +1,143 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { PiAcpAgent } from '../../src/acp/agent.js'
4
+ import { FakeAgentSideConnection, FakePiRpcProcess, asAgentConn } from '../helpers/fakes.js'
5
+
6
+ class FakeSessions {
7
+ constructor(private readonly session: any) {}
8
+ maybeGet(_id: string) {
9
+ return this.session
10
+ }
11
+ get(_id: string) {
12
+ return this.session
13
+ }
14
+ }
15
+
16
+ test('PiAcpAgent: /steering reports current steeringMode', async () => {
17
+ const conn = new FakeAgentSideConnection()
18
+ const proc = new FakePiRpcProcess() as any
19
+ proc.getState = async () => ({ steeringMode: 'all' })
20
+
21
+ const agent = new PiAcpAgent(asAgentConn(conn))
22
+ ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc }) as any
23
+
24
+ const res = await agent.prompt({
25
+ sessionId: 's1',
26
+ prompt: [{ type: 'text', text: '/steering' }]
27
+ } as any)
28
+
29
+ assert.equal(res.stopReason, 'end_turn')
30
+ const last = conn.updates.at(-1)
31
+ assert.equal(last?.update?.sessionUpdate, 'agent_message_chunk')
32
+ assert.match((last as any).update.content.text, /Steering mode: all/)
33
+ })
34
+
35
+ test('PiAcpAgent: /steering sets steering mode', async () => {
36
+ const conn = new FakeAgentSideConnection()
37
+ const proc = new FakePiRpcProcess() as any
38
+ let setTo: string | null = null
39
+ proc.getState = async () => ({ steeringMode: 'all' })
40
+ proc.setSteeringMode = async (m: string) => {
41
+ setTo = m
42
+ }
43
+
44
+ const agent = new PiAcpAgent(asAgentConn(conn))
45
+ ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc }) as any
46
+
47
+ const res = await agent.prompt({
48
+ sessionId: 's1',
49
+ prompt: [{ type: 'text', text: '/steering one-at-a-time' }]
50
+ } as any)
51
+
52
+ assert.equal(res.stopReason, 'end_turn')
53
+ assert.equal(setTo, 'one-at-a-time')
54
+ const last = conn.updates.at(-1)
55
+ assert.match((last as any).update.content.text, /Steering mode set to: one-at-a-time/)
56
+ })
57
+
58
+ test('PiAcpAgent: /steering rejects invalid value', async () => {
59
+ const conn = new FakeAgentSideConnection()
60
+ const proc = new FakePiRpcProcess() as any
61
+ let called = false
62
+ proc.getState = async () => ({ steeringMode: 'all' })
63
+ proc.setSteeringMode = async () => {
64
+ called = true
65
+ }
66
+
67
+ const agent = new PiAcpAgent(asAgentConn(conn))
68
+ ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc }) as any
69
+
70
+ const res = await agent.prompt({
71
+ sessionId: 's1',
72
+ prompt: [{ type: 'text', text: '/steering nope' }]
73
+ } as any)
74
+
75
+ assert.equal(res.stopReason, 'end_turn')
76
+ assert.equal(called, false)
77
+ const last = conn.updates.at(-1)
78
+ assert.match((last as any).update.content.text, /Usage: \/steering/)
79
+ })
80
+
81
+ test('PiAcpAgent: /follow-up reports current followUpMode', async () => {
82
+ const conn = new FakeAgentSideConnection()
83
+ const proc = new FakePiRpcProcess() as any
84
+ proc.getState = async () => ({ followUpMode: 'one-at-a-time' })
85
+
86
+ const agent = new PiAcpAgent(asAgentConn(conn))
87
+ ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc }) as any
88
+
89
+ const res = await agent.prompt({
90
+ sessionId: 's1',
91
+ prompt: [{ type: 'text', text: '/follow-up' }]
92
+ } as any)
93
+
94
+ assert.equal(res.stopReason, 'end_turn')
95
+ const last = conn.updates.at(-1)
96
+ assert.match((last as any).update.content.text, /Follow-up mode: one-at-a-time/)
97
+ })
98
+
99
+ test('PiAcpAgent: /follow-up sets follow-up mode', async () => {
100
+ const conn = new FakeAgentSideConnection()
101
+ const proc = new FakePiRpcProcess() as any
102
+ let setTo: string | null = null
103
+ proc.getState = async () => ({ followUpMode: 'one-at-a-time' })
104
+ proc.setFollowUpMode = async (m: string) => {
105
+ setTo = m
106
+ }
107
+
108
+ const agent = new PiAcpAgent(asAgentConn(conn))
109
+ ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc }) as any
110
+
111
+ const res = await agent.prompt({
112
+ sessionId: 's1',
113
+ prompt: [{ type: 'text', text: '/follow-up all' }]
114
+ } as any)
115
+
116
+ assert.equal(res.stopReason, 'end_turn')
117
+ assert.equal(setTo, 'all')
118
+ const last = conn.updates.at(-1)
119
+ assert.match((last as any).update.content.text, /Follow-up mode set to: all/)
120
+ })
121
+
122
+ test('PiAcpAgent: /follow-up rejects invalid value', async () => {
123
+ const conn = new FakeAgentSideConnection()
124
+ const proc = new FakePiRpcProcess() as any
125
+ let called = false
126
+ proc.getState = async () => ({ followUpMode: 'one-at-a-time' })
127
+ proc.setFollowUpMode = async () => {
128
+ called = true
129
+ }
130
+
131
+ const agent = new PiAcpAgent(asAgentConn(conn))
132
+ ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc }) as any
133
+
134
+ const res = await agent.prompt({
135
+ sessionId: 's1',
136
+ prompt: [{ type: 'text', text: '/follow-up ???' }]
137
+ } as any)
138
+
139
+ assert.equal(res.stopReason, 'end_turn')
140
+ assert.equal(called, false)
141
+ const last = conn.updates.at(-1)
142
+ assert.match((last as any).update.content.text, /Usage: \/follow-up/)
143
+ })