@tduyng/pi-claude 0.0.0-stage → 0.0.2
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/LICENSE +178 -0
- package/README.md +86 -2
- package/package.json +40 -4
- package/src/index.ts +170 -0
- package/src/json.ts +74 -0
- package/src/mcp.ts +288 -0
- package/src/protocol.ts +335 -0
- package/src/session.ts +237 -0
- package/src/transcript.ts +213 -0
- package/src/turn.ts +657 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
2
|
+
|
|
3
|
+
import { Validator } from 'ata-validator'
|
|
4
|
+
import { t } from 'ata-validator/t'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import type { JsonObject, JsonValue } from './json.js'
|
|
8
|
+
const isStringOrNumber = new Validator(t.union([t.string(), t.number()])).isValidObject
|
|
9
|
+
const isString = new Validator(t.string()).isValidObject
|
|
10
|
+
|
|
11
|
+
export type McpToolDef = {
|
|
12
|
+
name: string
|
|
13
|
+
description: string
|
|
14
|
+
inputSchema: JsonObject
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type McpContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
|
|
18
|
+
|
|
19
|
+
export type McpResult = {
|
|
20
|
+
content: McpContent[]
|
|
21
|
+
isError?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type McpHandlers = {
|
|
25
|
+
listTools(): McpToolDef[]
|
|
26
|
+
callTool(name: string, args: JsonObject, meta: JsonObject): Promise<McpResult>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type McpServerHandle = {
|
|
30
|
+
port: number
|
|
31
|
+
urlFor(token: string): string
|
|
32
|
+
register(token: string, handlers: McpHandlers): void
|
|
33
|
+
unregister(token: string): void
|
|
34
|
+
close(): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SERVER_INFO = { name: 'pi', version: '1.0.0' }
|
|
38
|
+
const DEFAULT_PROTOCOL = '2025-11-25'
|
|
39
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
type RpcId = string | number
|
|
42
|
+
|
|
43
|
+
type McpRequest =
|
|
44
|
+
| { kind: 'malformed' }
|
|
45
|
+
| { kind: 'notification' }
|
|
46
|
+
| { kind: 'initialize'; id: RpcId; protocolVersion: string }
|
|
47
|
+
| { kind: 'ping'; id: RpcId }
|
|
48
|
+
| { kind: 'listTools'; id: RpcId }
|
|
49
|
+
| { kind: 'callTool'; id: RpcId; name: string; args: JsonObject; meta: JsonObject }
|
|
50
|
+
| { kind: 'unsupported'; id: RpcId; method: string }
|
|
51
|
+
|
|
52
|
+
const ObjectSchema = t.object({}, { additionalProperties: true })
|
|
53
|
+
const isObjectSchema = new Validator(ObjectSchema).isValidObject
|
|
54
|
+
|
|
55
|
+
function isJsonObject<V>(value: V): value is V & JsonObject {
|
|
56
|
+
return isObjectSchema(value) && !Array.isArray(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function asObject(value: JsonValue | undefined): JsonObject {
|
|
60
|
+
return isJsonObject(value) ? value : {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseRequest(body: string): McpRequest {
|
|
64
|
+
let frame: JsonObject
|
|
65
|
+
try {
|
|
66
|
+
const parsed: JsonValue = JSON.parse(body)
|
|
67
|
+
frame = asObject(parsed)
|
|
68
|
+
} catch {
|
|
69
|
+
return { kind: 'malformed' }
|
|
70
|
+
}
|
|
71
|
+
const id = frame['id']
|
|
72
|
+
if (!isStringOrNumber(id)) return { kind: 'notification' }
|
|
73
|
+
|
|
74
|
+
const method = frame['method']
|
|
75
|
+
if (!isString(method)) return { kind: 'malformed' }
|
|
76
|
+
const params = asObject(frame['params'])
|
|
77
|
+
|
|
78
|
+
switch (method) {
|
|
79
|
+
case 'initialize': {
|
|
80
|
+
const requested = params['protocolVersion']
|
|
81
|
+
return {
|
|
82
|
+
kind: 'initialize',
|
|
83
|
+
id,
|
|
84
|
+
protocolVersion: isString(requested) ? requested : DEFAULT_PROTOCOL
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
case 'ping':
|
|
88
|
+
return { kind: 'ping', id }
|
|
89
|
+
case 'tools/list':
|
|
90
|
+
return { kind: 'listTools', id }
|
|
91
|
+
case 'tools/call': {
|
|
92
|
+
const name = params['name']
|
|
93
|
+
if (!isString(name)) return { kind: 'malformed' }
|
|
94
|
+
return { kind: 'callTool', id, name, args: asObject(params['arguments']), meta: asObject(params['_meta']) }
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
return { kind: 'unsupported', id, method }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class RequestBodyError extends Error {
|
|
102
|
+
readonly status = 413
|
|
103
|
+
|
|
104
|
+
constructor() {
|
|
105
|
+
super(`MCP request body exceeds ${MAX_BODY_BYTES} bytes`)
|
|
106
|
+
this.name = 'RequestBodyError'
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
let body = ''
|
|
113
|
+
let bytes = 0
|
|
114
|
+
let rejected = false
|
|
115
|
+
req.setEncoding('utf8')
|
|
116
|
+
req.on('data', (chunk: string) => {
|
|
117
|
+
if (rejected) return
|
|
118
|
+
bytes += Buffer.byteLength(chunk, 'utf8')
|
|
119
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
120
|
+
rejected = true
|
|
121
|
+
reject(new RequestBodyError())
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
body += chunk
|
|
125
|
+
})
|
|
126
|
+
req.on('end', () => {
|
|
127
|
+
if (!rejected) resolve(body)
|
|
128
|
+
})
|
|
129
|
+
req.on('error', reject)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type RpcResult =
|
|
134
|
+
| {
|
|
135
|
+
protocolVersion: string
|
|
136
|
+
capabilities: { tools: { listChanged?: boolean } }
|
|
137
|
+
serverInfo: { name: string; version: string }
|
|
138
|
+
}
|
|
139
|
+
| { alive?: true }
|
|
140
|
+
| { tools: McpToolDef[] }
|
|
141
|
+
| McpResult
|
|
142
|
+
|
|
143
|
+
type RpcPayload =
|
|
144
|
+
| { jsonrpc: '2.0'; id: RpcId; result: RpcResult }
|
|
145
|
+
| { jsonrpc: '2.0'; id: RpcId; error: { code: number; message: string } }
|
|
146
|
+
|
|
147
|
+
type RpcReply = { status: number; body?: RpcPayload }
|
|
148
|
+
|
|
149
|
+
function ok(id: RpcId, result: RpcResult): RpcPayload {
|
|
150
|
+
return { jsonrpc: '2.0', id, result }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function send(res: ServerResponse, status: number, payload: RpcPayload | undefined, sessionId: string): void {
|
|
154
|
+
if (payload === undefined) {
|
|
155
|
+
res.writeHead(status, { 'mcp-session-id': sessionId }).end()
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const body = JSON.stringify(payload)
|
|
159
|
+
res.writeHead(status, {
|
|
160
|
+
'content-type': 'application/json',
|
|
161
|
+
'content-length': Buffer.byteLength(body),
|
|
162
|
+
'mcp-session-id': sessionId
|
|
163
|
+
}).end(body)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function respond(request: McpRequest, handlers: McpHandlers): Promise<RpcReply> {
|
|
167
|
+
switch (request.kind) {
|
|
168
|
+
case 'malformed':
|
|
169
|
+
return { status: 400 }
|
|
170
|
+
case 'notification':
|
|
171
|
+
return { status: 202 }
|
|
172
|
+
case 'initialize':
|
|
173
|
+
return {
|
|
174
|
+
status: 200,
|
|
175
|
+
body: ok(request.id, {
|
|
176
|
+
protocolVersion: request.protocolVersion,
|
|
177
|
+
capabilities: { tools: { listChanged: false } },
|
|
178
|
+
serverInfo: SERVER_INFO
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
case 'ping':
|
|
182
|
+
return { status: 200, body: ok(request.id, {}) }
|
|
183
|
+
case 'listTools':
|
|
184
|
+
return { status: 200, body: ok(request.id, { tools: handlers.listTools() }) }
|
|
185
|
+
case 'callTool': {
|
|
186
|
+
let result: McpResult
|
|
187
|
+
try {
|
|
188
|
+
result = await handlers.callTool(request.name, request.args, request.meta)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
result = {
|
|
191
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
192
|
+
isError: true
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { status: 200, body: ok(request.id, result) }
|
|
196
|
+
}
|
|
197
|
+
case 'unsupported':
|
|
198
|
+
return {
|
|
199
|
+
status: 200,
|
|
200
|
+
body: {
|
|
201
|
+
jsonrpc: '2.0',
|
|
202
|
+
id: request.id,
|
|
203
|
+
error: { code: -32601, message: `unsupported method: ${request.method}` }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function mcpInputSchema(parameters: JsonObject | undefined): JsonObject {
|
|
210
|
+
if (!parameters) return { type: 'object', additionalProperties: false }
|
|
211
|
+
const schema = { ...parameters }
|
|
212
|
+
const required = schema['required']
|
|
213
|
+
if (required !== undefined && !Array.isArray(required) && isObjectSchema(required)) {
|
|
214
|
+
schema['required'] = Object.keys(required)
|
|
215
|
+
}
|
|
216
|
+
if (schema['required'] === undefined) schema['required'] = []
|
|
217
|
+
return schema
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function startMcpServer(): Promise<McpServerHandle> {
|
|
221
|
+
const registry = new Map<string, McpHandlers>()
|
|
222
|
+
|
|
223
|
+
const server: Server = createServer((req, res) => {
|
|
224
|
+
void (async () => {
|
|
225
|
+
const token = /^\/mcp\/([A-Za-z0-9_-]+)\/?$/.exec(req.url ?? '')?.[1]
|
|
226
|
+
const handlers = token ? registry.get(token) : undefined
|
|
227
|
+
if (!token || !handlers) {
|
|
228
|
+
send(res, 404, undefined, 'missing')
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (req.method === 'GET') {
|
|
232
|
+
res.writeHead(200, {
|
|
233
|
+
'content-type': 'text/event-stream',
|
|
234
|
+
'cache-control': 'no-cache',
|
|
235
|
+
'mcp-session-id': token
|
|
236
|
+
})
|
|
237
|
+
res.write(': connected\n\n')
|
|
238
|
+
res.on('close', () => undefined)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (req.method === 'DELETE') {
|
|
242
|
+
res.writeHead(200, { 'mcp-session-id': token }).end()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (req.method !== 'POST') {
|
|
246
|
+
send(res, 405, undefined, token)
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const request = parseRequest(await readBody(req))
|
|
251
|
+
const reply = await respond(request, handlers)
|
|
252
|
+
send(res, reply.status, reply.body, token)
|
|
253
|
+
})().catch((error) => {
|
|
254
|
+
if (!res.headersSent) send(res, error instanceof RequestBodyError ? error.status : 500, undefined, 'error')
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
server.requestTimeout = 0
|
|
259
|
+
server.headersTimeout = 60_000
|
|
260
|
+
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
server.on('error', reject)
|
|
263
|
+
server.listen(0, '127.0.0.1', () => {
|
|
264
|
+
const address = server.address()
|
|
265
|
+
if (address === null || isString(address)) {
|
|
266
|
+
reject(new Error('MCP server did not bind to a TCP port'))
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
server.unref()
|
|
270
|
+
resolve({
|
|
271
|
+
port: address.port,
|
|
272
|
+
urlFor: (token) => `http://127.0.0.1:${address.port}/mcp/${token}`,
|
|
273
|
+
register: (token, handlers) => {
|
|
274
|
+
registry.set(token, handlers)
|
|
275
|
+
},
|
|
276
|
+
unregister: (token) => {
|
|
277
|
+
registry.delete(token)
|
|
278
|
+
},
|
|
279
|
+
close: () =>
|
|
280
|
+
new Promise((done) => {
|
|
281
|
+
registry.clear()
|
|
282
|
+
server.closeAllConnections()
|
|
283
|
+
server.close(() => done())
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { createInterface } from 'node:readline'
|
|
6
|
+
|
|
7
|
+
import { Validator } from 'ata-validator'
|
|
8
|
+
import { t } from 'ata-validator/t'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
import { jsonArray, jsonObject, jsonText, type JsonObject, type JsonValue } from './json.js'
|
|
12
|
+
import type { CliBlock } from './transcript.js'
|
|
13
|
+
|
|
14
|
+
export type ClaudeSpec = {
|
|
15
|
+
bin: string
|
|
16
|
+
model: string
|
|
17
|
+
systemPrompt: string
|
|
18
|
+
effort?: string
|
|
19
|
+
cwd: string
|
|
20
|
+
mcpUrl: string
|
|
21
|
+
contextWindow: number
|
|
22
|
+
maxTokens: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const INIT_TIMEOUT_MS = 45_000
|
|
26
|
+
const INIT_SCHEMA = t.object({
|
|
27
|
+
type: t.literal('system'),
|
|
28
|
+
subtype: t.literal('init'),
|
|
29
|
+
mcp_servers: t.optional(t.array(t.object({}, { additionalProperties: true })))
|
|
30
|
+
})
|
|
31
|
+
const isInitSchema = new Validator(INIT_SCHEMA).isValidObject
|
|
32
|
+
|
|
33
|
+
class EventQueue {
|
|
34
|
+
private readonly items: JsonObject[] = []
|
|
35
|
+
private readonly waiters: ((event: JsonObject | undefined) => void)[] = []
|
|
36
|
+
private closed = false
|
|
37
|
+
|
|
38
|
+
push(event: JsonObject): void {
|
|
39
|
+
const waiter = this.waiters.shift()
|
|
40
|
+
if (waiter) waiter(event)
|
|
41
|
+
else this.items.push(event)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
close(): void {
|
|
45
|
+
if (this.closed) return
|
|
46
|
+
this.closed = true
|
|
47
|
+
for (const waiter of this.waiters.splice(0)) waiter(undefined)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
next(signal?: AbortSignal): Promise<JsonObject | undefined> {
|
|
51
|
+
const queued = this.items.shift()
|
|
52
|
+
if (queued) return Promise.resolve(queued)
|
|
53
|
+
if (this.closed) return Promise.resolve(undefined)
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
let settled = false
|
|
57
|
+
const waiter = (event: JsonObject | undefined) => {
|
|
58
|
+
if (settled) return
|
|
59
|
+
settled = true
|
|
60
|
+
signal?.removeEventListener('abort', onAbort)
|
|
61
|
+
resolve(event)
|
|
62
|
+
}
|
|
63
|
+
const onAbort = () => {
|
|
64
|
+
if (settled) return
|
|
65
|
+
settled = true
|
|
66
|
+
this.waiters.splice(this.waiters.indexOf(waiter), 1)
|
|
67
|
+
reject(new Error('aborted'))
|
|
68
|
+
}
|
|
69
|
+
this.waiters.push(waiter)
|
|
70
|
+
if (signal?.aborted) {
|
|
71
|
+
onAbort()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function childEnv(spec: ClaudeSpec): NodeJS.ProcessEnv {
|
|
80
|
+
const env: NodeJS.ProcessEnv = { ...process.env }
|
|
81
|
+
for (const key of [
|
|
82
|
+
'ANTHROPIC_API_KEY',
|
|
83
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
84
|
+
'ANTHROPIC_BASE_URL',
|
|
85
|
+
'ANTHROPIC_MODEL',
|
|
86
|
+
'ANTHROPIC_SMALL_FAST_MODEL',
|
|
87
|
+
'CLAUDE_CODE_USE_BEDROCK',
|
|
88
|
+
'CLAUDE_CODE_USE_VERTEX',
|
|
89
|
+
'CLAUDE_CODE_USE_FOUNDRY',
|
|
90
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
91
|
+
'CLAUDECODE',
|
|
92
|
+
'CLAUDE_CODE_ENTRYPOINT'
|
|
93
|
+
]) {
|
|
94
|
+
delete env[key]
|
|
95
|
+
}
|
|
96
|
+
Object.assign(env, {
|
|
97
|
+
CLAUDE_CODE_DISABLE_CLAUDE_MDS: '1',
|
|
98
|
+
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1',
|
|
99
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
100
|
+
CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: '1',
|
|
101
|
+
CLAUDE_CODE_DISABLE_POLICY_SKILLS: '1',
|
|
102
|
+
CLAUDE_CODE_DISABLE_CLAUDE_API_SKILL: '1',
|
|
103
|
+
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1',
|
|
104
|
+
CLAUDE_CODE_DISABLE_AGENT_VIEW: '1',
|
|
105
|
+
CLAUDE_CODE_DISABLE_CRON: '1',
|
|
106
|
+
CLAUDE_CODE_DISABLE_ARTIFACT: '1',
|
|
107
|
+
CLAUDE_CODE_DISABLE_ADVISOR_TOOL: '1',
|
|
108
|
+
CLAUDE_CODE_DISABLE_FAST_MODE: '1',
|
|
109
|
+
CLAUDE_CODE_DISABLE_ATTACHMENTS: '1',
|
|
110
|
+
CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: '1',
|
|
111
|
+
CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: '1',
|
|
112
|
+
DISABLE_AUTO_COMPACT: '1',
|
|
113
|
+
DISABLE_COMPACT: '1',
|
|
114
|
+
DISABLE_TELEMETRY: '1',
|
|
115
|
+
DISABLE_ERROR_REPORTING: '1',
|
|
116
|
+
DISABLE_AUTOUPDATER: '1',
|
|
117
|
+
MCP_TIMEOUT: '30000',
|
|
118
|
+
MCP_TOOL_TIMEOUT: '86400000',
|
|
119
|
+
CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS: '0',
|
|
120
|
+
CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT: '0',
|
|
121
|
+
MAX_MCP_OUTPUT_TOKENS: '200000',
|
|
122
|
+
CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH: '20000',
|
|
123
|
+
CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(spec.contextWindow),
|
|
124
|
+
CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(spec.maxTokens)
|
|
125
|
+
})
|
|
126
|
+
return env
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function spawnArgs(spec: ClaudeSpec, promptPath: string, mcpPath: string): string[] {
|
|
130
|
+
const args = [
|
|
131
|
+
'-p',
|
|
132
|
+
'--input-format',
|
|
133
|
+
'stream-json',
|
|
134
|
+
'--output-format',
|
|
135
|
+
'stream-json',
|
|
136
|
+
'--verbose',
|
|
137
|
+
'--include-partial-messages',
|
|
138
|
+
'--model',
|
|
139
|
+
spec.model,
|
|
140
|
+
'--system-prompt-file',
|
|
141
|
+
promptPath,
|
|
142
|
+
'--tools',
|
|
143
|
+
'',
|
|
144
|
+
'--setting-sources',
|
|
145
|
+
'',
|
|
146
|
+
'--strict-mcp-config',
|
|
147
|
+
'--mcp-config',
|
|
148
|
+
mcpPath,
|
|
149
|
+
'--permission-mode',
|
|
150
|
+
'bypassPermissions',
|
|
151
|
+
'--allow-dangerously-skip-permissions',
|
|
152
|
+
'--no-session-persistence',
|
|
153
|
+
'--disable-slash-commands'
|
|
154
|
+
]
|
|
155
|
+
if (spec.effort) args.push('--effort', spec.effort)
|
|
156
|
+
return args
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mcpConfig(url: string): string {
|
|
160
|
+
return `${JSON.stringify({
|
|
161
|
+
mcpServers: {
|
|
162
|
+
pi: { type: 'http', url, timeout: 86_400_000 }
|
|
163
|
+
}
|
|
164
|
+
})}\n`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class ClaudeProcess {
|
|
168
|
+
readonly spec: ClaudeSpec
|
|
169
|
+
private readonly queue = new EventQueue()
|
|
170
|
+
private child?: ChildProcessWithoutNullStreams
|
|
171
|
+
private dir?: string
|
|
172
|
+
private stderrText = ''
|
|
173
|
+
private exited?: Promise<void>
|
|
174
|
+
private dead = false
|
|
175
|
+
private requests = 0
|
|
176
|
+
|
|
177
|
+
constructor(spec: ClaudeSpec) {
|
|
178
|
+
this.spec = spec
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async start(first: CliBlock[]): Promise<void> {
|
|
182
|
+
try {
|
|
183
|
+
const dir = await mkdtemp(join(tmpdir(), 'pi-claude-'))
|
|
184
|
+
this.dir = dir
|
|
185
|
+
const promptPath = join(dir, 'system-prompt.txt')
|
|
186
|
+
const mcpPath = join(dir, 'mcp.json')
|
|
187
|
+
await writeFile(promptPath, this.spec.systemPrompt, 'utf8')
|
|
188
|
+
await writeFile(mcpPath, mcpConfig(this.spec.mcpUrl), 'utf8')
|
|
189
|
+
|
|
190
|
+
const child = spawn(this.spec.bin, spawnArgs(this.spec, promptPath, mcpPath), {
|
|
191
|
+
cwd: this.spec.cwd,
|
|
192
|
+
env: childEnv(this.spec),
|
|
193
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
194
|
+
})
|
|
195
|
+
this.child = child
|
|
196
|
+
this.exited = new Promise((resolve) => {
|
|
197
|
+
child.once('exit', () => resolve())
|
|
198
|
+
child.once('error', () => resolve())
|
|
199
|
+
})
|
|
200
|
+
child.once('error', (error) => {
|
|
201
|
+
this.dead = true
|
|
202
|
+
this.stderrText = `${this.stderrText}\n${error.message}`.trim()
|
|
203
|
+
this.queue.close()
|
|
204
|
+
})
|
|
205
|
+
child.once('exit', () => {
|
|
206
|
+
this.dead = true
|
|
207
|
+
this.queue.close()
|
|
208
|
+
})
|
|
209
|
+
child.stdin.on('error', () => {
|
|
210
|
+
this.dead = true
|
|
211
|
+
this.queue.close()
|
|
212
|
+
})
|
|
213
|
+
child.stderr.setEncoding('utf8')
|
|
214
|
+
child.stderr.on('data', (chunk: string) => {
|
|
215
|
+
this.stderrText = (this.stderrText + chunk).slice(-8000)
|
|
216
|
+
})
|
|
217
|
+
const lines = createInterface({ input: child.stdout })
|
|
218
|
+
lines.on('line', (line: string) => {
|
|
219
|
+
if (!line.startsWith('{')) return
|
|
220
|
+
try {
|
|
221
|
+
const event = jsonObject(JSON.parse(line))
|
|
222
|
+
if (event) this.queue.push(event)
|
|
223
|
+
} catch {
|
|
224
|
+
// A malformed line is not part of the stream contract; the next one may be.
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
this.sendUser(first)
|
|
228
|
+
await this.waitForInit()
|
|
229
|
+
} catch (error) {
|
|
230
|
+
await this.close()
|
|
231
|
+
throw error
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async waitForInit(): Promise<void> {
|
|
236
|
+
const deadline = Date.now() + INIT_TIMEOUT_MS
|
|
237
|
+
for (;;) {
|
|
238
|
+
const remaining = deadline - Date.now()
|
|
239
|
+
if (remaining <= 0)
|
|
240
|
+
throw new Error(`Claude Code did not start within ${INIT_TIMEOUT_MS}ms${this.stderrSuffix()}`)
|
|
241
|
+
let event: JsonObject | undefined
|
|
242
|
+
try {
|
|
243
|
+
event = await this.queue.next(AbortSignal.timeout(remaining))
|
|
244
|
+
} catch {
|
|
245
|
+
throw new Error(`Claude Code did not start within ${INIT_TIMEOUT_MS}ms${this.stderrSuffix()}`)
|
|
246
|
+
}
|
|
247
|
+
if (!event) throw new Error(`Claude Code exited during startup${this.stderrSuffix()}`)
|
|
248
|
+
if (!isInitSchema(event)) continue
|
|
249
|
+
const init = event
|
|
250
|
+
const servers = init.mcp_servers
|
|
251
|
+
if (!servers) return
|
|
252
|
+
let connected = false
|
|
253
|
+
for (const server of servers) {
|
|
254
|
+
const object = jsonObject(server)
|
|
255
|
+
if (!object || jsonText(object, 'name') !== 'pi') continue
|
|
256
|
+
connected = true
|
|
257
|
+
const status = jsonText(object, 'status')
|
|
258
|
+
if (status && /failed|error|disconnect/i.test(status)) {
|
|
259
|
+
throw new Error(`Claude Code could not connect the Pi MCP server: ${status}${this.stderrSuffix()}`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!connected) throw new Error(`Claude Code did not connect the Pi MCP server${this.stderrSuffix()}`)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
sendUser(content: CliBlock[]): void {
|
|
268
|
+
if (!this.child || this.dead) throw new Error(`Claude Code is not running${this.stderrSuffix()}`)
|
|
269
|
+
const message = { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null }
|
|
270
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
next(signal?: AbortSignal): Promise<JsonObject | undefined> {
|
|
274
|
+
return this.queue.next(signal)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
isDead(): boolean {
|
|
278
|
+
return this.dead
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
stderrTail(): string {
|
|
282
|
+
return this.stderrText.trim()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private stderrSuffix(): string {
|
|
286
|
+
const tail = this.stderrTail()
|
|
287
|
+
return tail ? `\n${tail}` : ''
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interrupt(): void {
|
|
291
|
+
if (!this.child || this.dead) return
|
|
292
|
+
this.requests += 1
|
|
293
|
+
const message = {
|
|
294
|
+
type: 'control_request',
|
|
295
|
+
request_id: `pi-${this.requests}`,
|
|
296
|
+
request: { subtype: 'interrupt' }
|
|
297
|
+
}
|
|
298
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async close(): Promise<void> {
|
|
302
|
+
const child = this.child
|
|
303
|
+
this.child = undefined
|
|
304
|
+
this.dead = true
|
|
305
|
+
this.queue.close()
|
|
306
|
+
if (child) {
|
|
307
|
+
if (!child.stdin.destroyed) child.stdin.end()
|
|
308
|
+
child.kill('SIGINT')
|
|
309
|
+
await Promise.race([this.exited, new Promise((resolve) => setTimeout(resolve, 2000))])
|
|
310
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
|
|
311
|
+
}
|
|
312
|
+
if (this.dir) {
|
|
313
|
+
const dir = this.dir
|
|
314
|
+
this.dir = undefined
|
|
315
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function jsonEventType(event: JsonObject): string | undefined {
|
|
321
|
+
return jsonText(event, 'type')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function jsonEventSubtype(event: JsonObject): string | undefined {
|
|
325
|
+
return jsonText(event, 'subtype')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function jsonEventArray(event: JsonObject, key: string): JsonValue[] | undefined {
|
|
329
|
+
return jsonArray(event[key])
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function claudeBin(): string {
|
|
333
|
+
const value = process.env['PI_CLAUDE_BIN']
|
|
334
|
+
return value && value.length > 0 ? value : 'claude'
|
|
335
|
+
}
|