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