@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,197 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import type { AvailableCommand } from '@agentclientprotocol/sdk'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File-based slash command (mirrors pi-coding-agent semantics).
|
|
8
|
+
*/
|
|
9
|
+
export type FileSlashCommand = {
|
|
10
|
+
name: string
|
|
11
|
+
description: string
|
|
12
|
+
content: string
|
|
13
|
+
source: string // e.g. "(user)", "(project)", "(project:frontend)"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseFrontmatter(content: string): {
|
|
17
|
+
frontmatter: Record<string, string>
|
|
18
|
+
content: string
|
|
19
|
+
} {
|
|
20
|
+
const frontmatter: Record<string, string> = {}
|
|
21
|
+
|
|
22
|
+
if (!content.startsWith('---')) return { frontmatter, content }
|
|
23
|
+
|
|
24
|
+
const endIndex = content.indexOf('\n---', 3)
|
|
25
|
+
if (endIndex === -1) return { frontmatter, content }
|
|
26
|
+
|
|
27
|
+
const frontmatterBlock = content.slice(4, endIndex)
|
|
28
|
+
const remaining = content.slice(endIndex + 4).trim()
|
|
29
|
+
|
|
30
|
+
for (const line of frontmatterBlock.split('\n')) {
|
|
31
|
+
const match = line.match(/^(\w+):\s*(.*)$/)
|
|
32
|
+
if (match) frontmatter[match[1]] = match[2].trim()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { frontmatter, content: remaining }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadCommandsFromDir(dir: string, source: 'user' | 'project', subdir = ''): FileSlashCommand[] {
|
|
39
|
+
const commands: FileSlashCommand[] = []
|
|
40
|
+
if (!existsSync(dir)) return commands
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const fullPath = join(dir, entry.name)
|
|
47
|
+
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name
|
|
50
|
+
commands.push(...loadCommandsFromDir(fullPath, source, newSubdir))
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const rawContent = readFileSync(fullPath, 'utf-8')
|
|
58
|
+
const { frontmatter, content } = parseFrontmatter(rawContent)
|
|
59
|
+
|
|
60
|
+
const name = entry.name.slice(0, -3)
|
|
61
|
+
|
|
62
|
+
const sourceStr =
|
|
63
|
+
source === 'user' ? (subdir ? `(user:${subdir})` : '(user)') : subdir ? `(project:${subdir})` : '(project)'
|
|
64
|
+
|
|
65
|
+
let description = frontmatter.description || ''
|
|
66
|
+
if (!description) {
|
|
67
|
+
const firstLine = content.split('\n').find(l => l.trim())
|
|
68
|
+
if (firstLine) {
|
|
69
|
+
description = firstLine.slice(0, 60)
|
|
70
|
+
if (firstLine.length > 60) description += '...'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
description = description ? `${description} ${sourceStr}` : sourceStr
|
|
75
|
+
|
|
76
|
+
commands.push({
|
|
77
|
+
name,
|
|
78
|
+
description,
|
|
79
|
+
content,
|
|
80
|
+
source: sourceStr
|
|
81
|
+
})
|
|
82
|
+
} catch {
|
|
83
|
+
// Silently skip unreadable files.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Silently skip unreadable dirs.
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return commands
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Load prompt templates from pi's prompt directories (formerly "commands").
|
|
95
|
+
* - user: ~/.pi/agent/prompts/**\/*.md
|
|
96
|
+
* - project: <cwd>/.pi/prompts/**\/*.md
|
|
97
|
+
*/
|
|
98
|
+
export function loadSlashCommands(cwd: string): FileSlashCommand[] {
|
|
99
|
+
const commands: FileSlashCommand[] = []
|
|
100
|
+
|
|
101
|
+
const userDir = join(homedir(), '.pi', 'agent', 'prompts')
|
|
102
|
+
const projectDir = resolve(cwd, '.pi', 'prompts')
|
|
103
|
+
|
|
104
|
+
// Match pi ordering: user first, then project.
|
|
105
|
+
commands.push(...loadCommandsFromDir(userDir, 'user'))
|
|
106
|
+
commands.push(...loadCommandsFromDir(projectDir, 'project'))
|
|
107
|
+
|
|
108
|
+
return commands
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convert file-based commands to ACP AvailableCommand objects.
|
|
113
|
+
* De-dupes by name (first wins).
|
|
114
|
+
*/
|
|
115
|
+
export function toAvailableCommands(fileCommands: FileSlashCommand[]): AvailableCommand[] {
|
|
116
|
+
const seen = new Set<string>()
|
|
117
|
+
const out: AvailableCommand[] = []
|
|
118
|
+
|
|
119
|
+
for (const c of fileCommands) {
|
|
120
|
+
if (seen.has(c.name)) continue
|
|
121
|
+
seen.add(c.name)
|
|
122
|
+
|
|
123
|
+
out.push({
|
|
124
|
+
name: c.name,
|
|
125
|
+
description: c.description
|
|
126
|
+
// input: omitted for now (pi commands don't specify this)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse command args (bash-style quotes).
|
|
135
|
+
*/
|
|
136
|
+
export function parseCommandArgs(argsString: string): string[] {
|
|
137
|
+
const args: string[] = []
|
|
138
|
+
let current = ''
|
|
139
|
+
let inQuote: string | null = null
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < argsString.length; i++) {
|
|
142
|
+
const ch = argsString[i]
|
|
143
|
+
|
|
144
|
+
if (inQuote) {
|
|
145
|
+
if (ch === inQuote) inQuote = null
|
|
146
|
+
else current += ch
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (ch === '"' || ch === "'") {
|
|
151
|
+
inQuote = ch
|
|
152
|
+
} else if (ch === ' ' || ch === '\t') {
|
|
153
|
+
if (current) {
|
|
154
|
+
args.push(current)
|
|
155
|
+
current = ''
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
current += ch
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (current) args.push(current)
|
|
163
|
+
return args
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Substitute $1, $2, ... and $@.
|
|
168
|
+
*/
|
|
169
|
+
export function substituteArgs(content: string, args: string[]): string {
|
|
170
|
+
let result = content
|
|
171
|
+
|
|
172
|
+
result = result.replace(/\$@/g, args.join(' '))
|
|
173
|
+
result = result.replace(/\$(\d+)/g, (_m, num) => {
|
|
174
|
+
const idx = Number.parseInt(String(num), 10) - 1
|
|
175
|
+
return args[idx] ?? ''
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return result
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Expand a leading /command using the loaded file commands.
|
|
183
|
+
* Returns original text if it's not a known slash command.
|
|
184
|
+
*/
|
|
185
|
+
export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[]): string {
|
|
186
|
+
if (!text.startsWith('/')) return text
|
|
187
|
+
|
|
188
|
+
const spaceIndex = text.indexOf(' ')
|
|
189
|
+
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex)
|
|
190
|
+
const argsString = spaceIndex === -1 ? '' : text.slice(spaceIndex + 1)
|
|
191
|
+
|
|
192
|
+
const cmd = fileCommands.find(c => c.name === commandName)
|
|
193
|
+
if (!cmd) return text
|
|
194
|
+
|
|
195
|
+
const args = parseCommandArgs(argsString)
|
|
196
|
+
return substituteArgs(cmd.content, args)
|
|
197
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ToolCallContent } from '@agentclientprotocol/sdk'
|
|
2
|
+
|
|
3
|
+
type BashCommandRecord = {
|
|
4
|
+
command?: unknown
|
|
5
|
+
cmd?: unknown
|
|
6
|
+
args?: BashCommandRecord
|
|
7
|
+
input?: BashCommandRecord
|
|
8
|
+
rawInput?: BashCommandRecord
|
|
9
|
+
toolInput?: BashCommandRecord
|
|
10
|
+
details?: BashCommandRecord
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type BashResultRecord = {
|
|
14
|
+
content?: unknown
|
|
15
|
+
details?: unknown
|
|
16
|
+
stdout?: unknown
|
|
17
|
+
stderr?: unknown
|
|
18
|
+
output?: unknown
|
|
19
|
+
exitCode?: unknown
|
|
20
|
+
code?: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isBashTool(toolName: string): boolean {
|
|
24
|
+
return toolName.toLowerCase() === 'bash'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function bashCommand(value: unknown): string | undefined {
|
|
28
|
+
const record = value as BashCommandRecord | null | undefined
|
|
29
|
+
const command =
|
|
30
|
+
record?.command ??
|
|
31
|
+
record?.cmd ??
|
|
32
|
+
record?.args?.command ??
|
|
33
|
+
record?.args?.cmd ??
|
|
34
|
+
record?.input?.command ??
|
|
35
|
+
record?.input?.cmd ??
|
|
36
|
+
record?.rawInput?.command ??
|
|
37
|
+
record?.rawInput?.cmd ??
|
|
38
|
+
record?.toolInput?.command ??
|
|
39
|
+
record?.toolInput?.cmd ??
|
|
40
|
+
record?.details?.command ??
|
|
41
|
+
record?.details?.cmd
|
|
42
|
+
|
|
43
|
+
return typeof command === 'string' && command.trim() ? command : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function bashResultText(result: unknown): string {
|
|
47
|
+
const record = result as BashResultRecord | null | undefined
|
|
48
|
+
const content = record?.content
|
|
49
|
+
if (Array.isArray(content)) {
|
|
50
|
+
const texts = content
|
|
51
|
+
.map(c => {
|
|
52
|
+
const block = c as { type?: unknown; text?: unknown }
|
|
53
|
+
return block.type === 'text' && typeof block.text === 'string' ? block.text : ''
|
|
54
|
+
})
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
if (texts.length) return texts.join('')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const details = record?.details as BashResultRecord | null | undefined
|
|
60
|
+
const stdout =
|
|
61
|
+
(typeof details?.stdout === 'string' ? details.stdout : undefined) ??
|
|
62
|
+
(typeof record?.stdout === 'string' ? record.stdout : undefined) ??
|
|
63
|
+
(typeof details?.output === 'string' ? details.output : undefined) ??
|
|
64
|
+
(typeof record?.output === 'string' ? record.output : undefined)
|
|
65
|
+
const stderr =
|
|
66
|
+
(typeof details?.stderr === 'string' ? details.stderr : undefined) ??
|
|
67
|
+
(typeof record?.stderr === 'string' ? record.stderr : undefined)
|
|
68
|
+
|
|
69
|
+
return [stdout, stderr].filter((part): part is string => typeof part === 'string' && part.length > 0).join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function bashExitCode(result: unknown, isError: boolean): number {
|
|
73
|
+
const record = result as BashResultRecord | null | undefined
|
|
74
|
+
const details = record?.details as BashResultRecord | null | undefined
|
|
75
|
+
const exitCode = details?.exitCode ?? record?.exitCode ?? details?.code ?? record?.code
|
|
76
|
+
return typeof exitCode === 'number' ? exitCode : isError ? 1 : 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function bashOutputDelta(previous: string, next: string): string {
|
|
80
|
+
return next.startsWith(previous) ? next.slice(previous.length) : next
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function bashTerminalContent(toolCallId: string): ToolCallContent[] {
|
|
84
|
+
return [{ type: 'terminal', terminalId: toolCallId }] satisfies ToolCallContent[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function bashTerminalInfoMeta(toolCallId: string, cwd: string) {
|
|
88
|
+
// Zed renders ACP `execute` tools as display-only terminals when paired with
|
|
89
|
+
// terminal content plus terminal metadata. See ACP execute tool schema:
|
|
90
|
+
// https://agentclientprotocol.com/protocol/schema#param-execute
|
|
91
|
+
return { terminal_info: { terminal_id: toolCallId, cwd } }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function bashTerminalOutputMeta(toolCallId: string, data: string) {
|
|
95
|
+
return { terminal_output: { terminal_id: toolCallId, data } }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function bashTerminalExitMeta(toolCallId: string, exitCode: number) {
|
|
99
|
+
return { terminal_exit: { terminal_id: toolCallId, exit_code: exitCode, signal: null } }
|
|
100
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function normalizePiMessageText(content: unknown): string {
|
|
2
|
+
if (typeof content === 'string') return content
|
|
3
|
+
if (!Array.isArray(content)) return ''
|
|
4
|
+
return content
|
|
5
|
+
.map((c: any) => (c?.type === 'text' && typeof c.text === 'string' ? c.text : ''))
|
|
6
|
+
.filter(Boolean)
|
|
7
|
+
.join('')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizePiAssistantText(content: unknown): string {
|
|
11
|
+
// Assistant content is typically an array of blocks; only replay text blocks for MVP.
|
|
12
|
+
if (!Array.isArray(content)) return ''
|
|
13
|
+
return content
|
|
14
|
+
.map((c: any) => (c?.type === 'text' && typeof c.text === 'string' ? c.text : ''))
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.join('')
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function toolResultToText(result: unknown): string {
|
|
2
|
+
if (!result) return ''
|
|
3
|
+
|
|
4
|
+
const details = (result as any)?.details
|
|
5
|
+
|
|
6
|
+
// pi's edit tool returns a terse success message in content and the full unified diff in details.diff.
|
|
7
|
+
const diff = details?.diff
|
|
8
|
+
if (typeof diff === 'string' && diff.trim()) {
|
|
9
|
+
return diff
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// pi tool results generally look like: { content: [{type:"text", text:"..."}], details: {...} }
|
|
13
|
+
const content = (result as any).content
|
|
14
|
+
if (Array.isArray(content)) {
|
|
15
|
+
const texts = content
|
|
16
|
+
.map((c: any) => (c?.type === 'text' && typeof c.text === 'string' ? c.text : ''))
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
if (texts.length) return texts.join('')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The bash tool frequently returns stdout/stderr in `details` rather than content blocks.
|
|
22
|
+
const stdout =
|
|
23
|
+
(typeof details?.stdout === 'string' ? details.stdout : undefined) ??
|
|
24
|
+
(typeof (result as any)?.stdout === 'string' ? (result as any).stdout : undefined) ??
|
|
25
|
+
(typeof details?.output === 'string' ? details.output : undefined) ??
|
|
26
|
+
(typeof (result as any)?.output === 'string' ? (result as any).output : undefined)
|
|
27
|
+
|
|
28
|
+
const stderr =
|
|
29
|
+
(typeof details?.stderr === 'string' ? details.stderr : undefined) ??
|
|
30
|
+
(typeof (result as any)?.stderr === 'string' ? (result as any).stderr : undefined)
|
|
31
|
+
|
|
32
|
+
const exitCode =
|
|
33
|
+
(typeof details?.exitCode === 'number' ? details.exitCode : undefined) ??
|
|
34
|
+
(typeof (result as any)?.exitCode === 'number' ? (result as any).exitCode : undefined) ??
|
|
35
|
+
(typeof details?.code === 'number' ? details.code : undefined) ??
|
|
36
|
+
(typeof (result as any)?.code === 'number' ? (result as any).code : undefined)
|
|
37
|
+
|
|
38
|
+
if ((typeof stdout === 'string' && stdout.trim()) || (typeof stderr === 'string' && stderr.trim())) {
|
|
39
|
+
const parts: string[] = []
|
|
40
|
+
if (typeof stdout === 'string' && stdout.trim()) parts.push(stdout)
|
|
41
|
+
if (typeof stderr === 'string' && stderr.trim()) parts.push(`stderr:\n${stderr}`)
|
|
42
|
+
if (typeof exitCode === 'number') parts.push(`exit code: ${exitCode}`)
|
|
43
|
+
return parts.join('\n\n').trimEnd()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return JSON.stringify(result, null, 2)
|
|
48
|
+
} catch {
|
|
49
|
+
return String(result)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ContentBlock } from '@agentclientprotocol/sdk'
|
|
2
|
+
|
|
3
|
+
export type PiImage = {
|
|
4
|
+
type: 'image'
|
|
5
|
+
mimeType: string
|
|
6
|
+
data: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function promptToPiMessage(blocks: ContentBlock[]): {
|
|
10
|
+
message: string
|
|
11
|
+
images: PiImage[]
|
|
12
|
+
} {
|
|
13
|
+
let message = ''
|
|
14
|
+
const images: PiImage[] = []
|
|
15
|
+
|
|
16
|
+
for (const b of blocks) {
|
|
17
|
+
switch (b.type) {
|
|
18
|
+
case 'text':
|
|
19
|
+
message += b.text
|
|
20
|
+
break
|
|
21
|
+
|
|
22
|
+
case 'resource_link':
|
|
23
|
+
// A lightweight, human-readable hint for the LLM.
|
|
24
|
+
message += `\n[Context] ${b.uri}`
|
|
25
|
+
break
|
|
26
|
+
|
|
27
|
+
case 'image': {
|
|
28
|
+
// pi expects base64 image bytes in `data` without a data-url prefix.
|
|
29
|
+
images.push({
|
|
30
|
+
type: 'image',
|
|
31
|
+
mimeType: b.mimeType,
|
|
32
|
+
data: b.data
|
|
33
|
+
})
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
case 'resource': {
|
|
38
|
+
// Clients should not send this if embeddedContext=false, but be resilient.
|
|
39
|
+
const r: any = (b as any).resource
|
|
40
|
+
const uri = typeof r?.uri === 'string' ? r.uri : '(unknown)'
|
|
41
|
+
|
|
42
|
+
if (typeof r?.text === 'string') {
|
|
43
|
+
// TextResourceContents
|
|
44
|
+
const mime = typeof r?.mimeType === 'string' ? r.mimeType : 'text/plain'
|
|
45
|
+
message += `\n[Embedded Context] ${uri} (${mime})\n${r.text}`
|
|
46
|
+
} else if (typeof r?.blob === 'string') {
|
|
47
|
+
// BlobResourceContents
|
|
48
|
+
const mime = typeof r?.mimeType === 'string' ? r.mimeType : 'application/octet-stream'
|
|
49
|
+
const bytes = Buffer.byteLength(r.blob, 'base64')
|
|
50
|
+
message += `\n[Embedded Context] ${uri} (${mime}, ${bytes} bytes)`
|
|
51
|
+
} else {
|
|
52
|
+
message += `\n[Embedded Context] ${uri}`
|
|
53
|
+
}
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case 'audio': {
|
|
58
|
+
// Not supported by pi. Provide a marker so we don't silently drop context.
|
|
59
|
+
const bytes = Buffer.byteLength(b.data, 'base64')
|
|
60
|
+
message += `\n[Audio] (${b.mimeType}, ${bytes} bytes) not supported by pi-acp`
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
default:
|
|
65
|
+
// Ignore unknown block types for now.
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { message, images }
|
|
71
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { AgentSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'
|
|
2
|
+
import { PiAcpAgent } from './acp/agent.js'
|
|
3
|
+
import { getPiCommand, shouldUseShellForPiCommand } from './pi-rpc/command.js'
|
|
4
|
+
// Terminal Auth entrypoint. The ACP client launches the agent with `--terminal-login`.
|
|
5
|
+
if (process.argv.includes('--terminal-login')) {
|
|
6
|
+
const { spawnSync } = await import('node:child_process')
|
|
7
|
+
const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND)
|
|
8
|
+
const res = spawnSync(cmd, [], {
|
|
9
|
+
stdio: 'inherit',
|
|
10
|
+
env: process.env,
|
|
11
|
+
shell: shouldUseShellForPiCommand(cmd)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
if ((res as any).error && (res as any).error.code === 'ENOENT') {
|
|
15
|
+
process.stderr.write(
|
|
16
|
+
`pi-acp: could not start pi (command not found: ${cmd}). Install it via \`npm install -g @earendil-works/pi-coding-agent\` or ensure \`pi\` is on your PATH.\n`
|
|
17
|
+
)
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
process.exit(typeof res.status === 'number' ? res.status : 1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const input = new WritableStream<Uint8Array>({
|
|
25
|
+
write(chunk) {
|
|
26
|
+
return new Promise<void>(resolve => {
|
|
27
|
+
if ((process.stdout as any).destroyed || !process.stdout.writable) return resolve()
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
process.stdout.write(chunk, err => {
|
|
31
|
+
void err
|
|
32
|
+
resolve()
|
|
33
|
+
})
|
|
34
|
+
} catch {
|
|
35
|
+
// Common: ERR_STREAM_DESTROYED ("Cannot call write after a stream was destroyed").
|
|
36
|
+
resolve()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const output = new ReadableStream<Uint8Array>({
|
|
43
|
+
start(controller) {
|
|
44
|
+
process.stdin.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)))
|
|
45
|
+
process.stdin.on('end', () => controller.close())
|
|
46
|
+
process.stdin.on('error', err => controller.error(err))
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const stream = ndJsonStream(input, output)
|
|
51
|
+
|
|
52
|
+
const agent = new AgentSideConnection(conn => new PiAcpAgent(conn), stream)
|
|
53
|
+
|
|
54
|
+
function shutdown() {
|
|
55
|
+
try {
|
|
56
|
+
// Best-effort: dispose session subprocesses when the client disconnects.
|
|
57
|
+
;(agent as any)?.agent?.dispose?.()
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
process.exit(0)
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.stdin.on('end', shutdown)
|
|
69
|
+
process.stdin.on('close', shutdown)
|
|
70
|
+
|
|
71
|
+
process.stdin.resume()
|
|
72
|
+
process.on('SIGINT', shutdown)
|
|
73
|
+
process.on('SIGTERM', shutdown)
|
|
74
|
+
|
|
75
|
+
// Avoid crashing if the client closes stdout early.
|
|
76
|
+
process.stdout.on('error', () => {
|
|
77
|
+
try {
|
|
78
|
+
process.exit(0)
|
|
79
|
+
} catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { platform } from 'node:os'
|
|
2
|
+
|
|
3
|
+
export function defaultPiCommand(): string {
|
|
4
|
+
return platform() === 'win32' ? 'pi.cmd' : 'pi'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getPiCommand(override?: string): string {
|
|
8
|
+
return override ?? defaultPiCommand()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function shouldUseShellForPiCommand(cmd: string): boolean {
|
|
12
|
+
if (platform() !== 'win32') return false
|
|
13
|
+
|
|
14
|
+
const normalized = cmd.trim().toLowerCase()
|
|
15
|
+
return normalized.endsWith('.cmd') || normalized.endsWith('.bat')
|
|
16
|
+
}
|