@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,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
|
+
})
|