@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/src/session.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import type { AssistantMessage, Tool, ToolResultMessage } from '@earendil-works/pi-ai'
4
+
5
+ import { jsonText, sha, stableStringify, type JsonObject } from './json.js'
6
+ import type { McpResult } from './mcp.js'
7
+ import { ClaudeProcess, type ClaudeSpec } from './protocol.js'
8
+ import { fingerprint, toolNameFromMcp, toolResultMcp, type Transcript } from './transcript.js'
9
+
10
+ const MAX_SESSIONS = 8
11
+
12
+ export type SessionKey = {
13
+ model: string
14
+ systemPrompt: string
15
+ toolsHash: string
16
+ effort: string
17
+ cwd: string
18
+ contextWindow: number
19
+ maxTokens: number
20
+ }
21
+
22
+ export type ClaudeSession = {
23
+ token: string
24
+ key: SessionKey
25
+ spec: ClaudeSpec
26
+ tools: Tool[]
27
+ proc?: ClaudeProcess
28
+ chain: string
29
+ count: number
30
+ awaiting: Set<string>
31
+ parked: Map<string, (result: McpResult) => void>
32
+ ready: Map<string, McpResult>
33
+ servedByClaude: Set<string>
34
+ pendingTools: { id: string; name: string; args: JsonObject }[]
35
+ busy: boolean
36
+ dead: boolean
37
+ lastUsed: number
38
+ settling?: Promise<void>
39
+ }
40
+
41
+ export type ClaudeSessionStore = { sessions: Map<string, ClaudeSession> }
42
+
43
+ export type Attached = { session: ClaudeSession; prefix: number }
44
+
45
+ export function createSessionStore(): ClaudeSessionStore {
46
+ return { sessions: new Map() }
47
+ }
48
+
49
+ export function allSessions(store: ClaudeSessionStore): ClaudeSession[] {
50
+ return [...store.sessions.values()]
51
+ }
52
+
53
+ export function newSessionToken(): string {
54
+ return randomBytes(12).toString('hex')
55
+ }
56
+
57
+ export function sessionFor(
58
+ store: ClaudeSessionStore,
59
+ id: string,
60
+ token: string,
61
+ key: SessionKey,
62
+ spec: ClaudeSpec,
63
+ tools: Tool[],
64
+ transcript: Transcript
65
+ ): ClaudeSession {
66
+ const session: ClaudeSession = {
67
+ token,
68
+ key,
69
+ spec,
70
+ tools,
71
+ chain: transcript.chain[transcript.messages.length] ?? transcript.chain[0] ?? sha('prompt'),
72
+ count: transcript.messages.length,
73
+ awaiting: new Set(),
74
+ parked: new Map(),
75
+ ready: new Map(),
76
+ servedByClaude: new Set(),
77
+ pendingTools: [],
78
+ busy: true,
79
+ dead: false,
80
+ lastUsed: Date.now()
81
+ }
82
+ store.sessions.set(id, session)
83
+ evict(store)
84
+ return session
85
+ }
86
+
87
+ export function attachSession(
88
+ store: ClaudeSessionStore,
89
+ transcript: Transcript,
90
+ key: SessionKey
91
+ ): Attached | undefined {
92
+ for (const session of store.sessions.values()) {
93
+ if (session.dead || session.busy) continue
94
+ if (!sameKey(session.key, key)) continue
95
+ if (session.count > transcript.messages.length) continue
96
+ if (transcript.chain[session.count] !== session.chain) continue
97
+
98
+ const tail = transcript.messages.slice(session.count)
99
+ if (tail.some((message) => message.role === 'assistant')) continue
100
+
101
+ const resultIds = tail.flatMap((message) => (message.role === 'toolResult' ? [message.toolCallId] : []))
102
+ if (!resultIds.every((id) => session.awaiting.has(id))) continue
103
+
104
+ session.busy = true
105
+ session.lastUsed = Date.now()
106
+ return { session, prefix: session.count }
107
+ }
108
+ return undefined
109
+ }
110
+
111
+ function sameKey(left: SessionKey, right: SessionKey): boolean {
112
+ return (
113
+ left.model === right.model &&
114
+ left.systemPrompt === right.systemPrompt &&
115
+ left.toolsHash === right.toolsHash &&
116
+ left.effort === right.effort &&
117
+ left.cwd === right.cwd &&
118
+ left.contextWindow === right.contextWindow &&
119
+ left.maxTokens === right.maxTokens
120
+ )
121
+ }
122
+
123
+ export function updateChain(session: ClaudeSession, transcript: Transcript, output: AssistantMessage): void {
124
+ const base = transcript.chain[transcript.messages.length] ?? session.chain
125
+ session.chain = sha(`${base}\0${fingerprint(output)}`)
126
+ session.count = transcript.messages.length + 1
127
+ session.lastUsed = Date.now()
128
+ }
129
+
130
+ export function parkToolCall(
131
+ session: ClaudeSession,
132
+ name: string,
133
+ args: JsonObject,
134
+ meta: JsonObject
135
+ ): Promise<McpResult> {
136
+ const toolName = toolNameFromMcp(name)
137
+ if (!session.tools.some((tool) => tool.name === toolName)) {
138
+ return Promise.resolve({
139
+ content: [{ type: 'text', text: `Pi bridge: unknown tool ${toolName}` }],
140
+ isError: true
141
+ })
142
+ }
143
+ let id = jsonText(meta, 'claudecode/toolUseId')
144
+ if (id) {
145
+ const index = session.pendingTools.findIndex((call) => call.id === id)
146
+ if (index >= 0) session.pendingTools.splice(index, 1)
147
+ } else {
148
+ const index = session.pendingTools.findIndex(
149
+ (call) => call.name === toolName && stableStringify(call.args) === stableStringify(args)
150
+ )
151
+ const call = index >= 0 ? session.pendingTools.splice(index, 1)[0] : undefined
152
+ id = call?.id
153
+ }
154
+ if (!id) {
155
+ return Promise.resolve({
156
+ content: [{ type: 'text', text: 'Pi bridge: tool call without a tool_use id' }],
157
+ isError: true
158
+ })
159
+ }
160
+ const ready = session.ready.get(id)
161
+ if (ready) {
162
+ session.ready.delete(id)
163
+ return Promise.resolve(ready)
164
+ }
165
+ if (session.parked.has(id)) {
166
+ return Promise.resolve({ content: [{ type: 'text', text: 'Pi bridge: duplicate tool call' }], isError: true })
167
+ }
168
+ return new Promise((resolve) => {
169
+ session.parked.set(id, resolve)
170
+ })
171
+ }
172
+
173
+ export function deliverToolResult(session: ClaudeSession, message: ToolResultMessage): void {
174
+ const result = toolResultMcp(message)
175
+ const resolve = session.parked.get(message.toolCallId)
176
+ if (resolve) {
177
+ session.parked.delete(message.toolCallId)
178
+ resolve(result)
179
+ return
180
+ }
181
+ session.ready.set(message.toolCallId, result)
182
+ }
183
+
184
+ export function failMissingResults(session: ClaudeSession, seen: Set<string>): void {
185
+ for (const id of session.awaiting) {
186
+ if (seen.has(id)) continue
187
+ const resolve = session.parked.get(id)
188
+ const result: McpResult = {
189
+ content: [{ type: 'text', text: 'Pi did not return a result for this tool call.' }],
190
+ isError: true
191
+ }
192
+ if (resolve) {
193
+ session.parked.delete(id)
194
+ resolve(result)
195
+ } else {
196
+ session.ready.set(id, result)
197
+ }
198
+ }
199
+ }
200
+
201
+ export function failParked(session: ClaudeSession, reason: string): void {
202
+ for (const resolve of session.parked.values()) {
203
+ resolve({ content: [{ type: 'text', text: reason }], isError: true })
204
+ }
205
+ session.parked.clear()
206
+ session.ready.clear()
207
+ session.pendingTools.length = 0
208
+ }
209
+
210
+ export function dropSession(store: ClaudeSessionStore, session: ClaudeSession): void {
211
+ session.dead = true
212
+ for (const [id, candidate] of store.sessions) if (candidate === session) store.sessions.delete(id)
213
+ failParked(session, 'The Pi session closed this Claude Code process.')
214
+ void session.proc?.close()
215
+ }
216
+
217
+ export function dropSessions(store: ClaudeSessionStore): void {
218
+ for (const session of store.sessions.values()) dropSession(store, session)
219
+ }
220
+
221
+ export function sweep(store: ClaudeSessionStore, idleMs: number): void {
222
+ const now = Date.now()
223
+ for (const session of store.sessions.values()) {
224
+ if (!session.busy && now - session.lastUsed > idleMs) dropSession(store, session)
225
+ }
226
+ }
227
+
228
+ function evict(store: ClaudeSessionStore): void {
229
+ if (store.sessions.size <= MAX_SESSIONS) return
230
+ const idle = [...store.sessions.values()]
231
+ .filter((session) => !session.busy && session.awaiting.size === 0 && session.parked.size === 0)
232
+ .toSorted((left, right) => left.lastUsed - right.lastUsed)
233
+ while (store.sessions.size > MAX_SESSIONS && idle.length > 0) {
234
+ const session = idle.shift()
235
+ if (session) dropSession(store, session)
236
+ }
237
+ }
@@ -0,0 +1,213 @@
1
+ import {
2
+ getCurrentTools,
3
+ getInitialSystemMessage,
4
+ getSystemMessageText,
5
+ renderSystemMessageUpdate,
6
+ toToolDeclaration,
7
+ type AssistantMessage,
8
+ type ImageContent,
9
+ type Message,
10
+ type SystemMessage,
11
+ type TextContent,
12
+ type Tool,
13
+ type ToolResultMessage,
14
+ type UserMessage
15
+ } from '@earendil-works/pi-ai'
16
+
17
+ import { jsonObject, sha, stableStringify, type JsonValue } from './json.js'
18
+ import { mcpInputSchema, type McpResult, type McpToolDef } from './mcp.js'
19
+
20
+ export type ClaudeContext = { messages: Message[] }
21
+ export type TurnMessage = UserMessage | AssistantMessage | ToolResultMessage | SystemMessage
22
+
23
+ export type Transcript = {
24
+ systemPrompt: string
25
+ tools: Tool[]
26
+ toolsHash: string
27
+ messages: TurnMessage[]
28
+ chain: string[]
29
+ }
30
+
31
+ export type CliTextBlock = { type: 'text'; text: string }
32
+ export type CliImageBlock = { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
33
+ export type CliBlock = CliTextBlock | CliImageBlock
34
+
35
+ const MCP_PREFIX = 'mcp__pi__'
36
+
37
+ export function readTranscript(context: ClaudeContext): Transcript {
38
+ const leading = getInitialSystemMessage(context.messages)
39
+ const systemPrompt = leading ? getSystemMessageText(leading) : ''
40
+ const tools = getCurrentTools(context.messages).map(toToolDeclaration)
41
+ const messages: TurnMessage[] = []
42
+ const dropped = new Set<string>()
43
+
44
+ for (const message of context.messages) {
45
+ if (message === leading) continue
46
+ if (message.role === 'assistant' && (message.stopReason === 'error' || message.stopReason === 'aborted')) {
47
+ for (const block of message.content) if (block.type === 'toolCall') dropped.add(block.id)
48
+ continue
49
+ }
50
+ if (message.role === 'toolResult' && dropped.has(message.toolCallId)) continue
51
+ messages.push(message)
52
+ }
53
+
54
+ const chain = [sha(`prompt\0${systemPrompt}`)]
55
+ for (const message of messages) chain.push(sha(`${chain[chain.length - 1]}\0${fingerprint(message)}`))
56
+ return { systemPrompt, tools, toolsHash: toolsHash(tools), messages, chain }
57
+ }
58
+
59
+ export function fingerprint(message: TurnMessage): string {
60
+ switch (message.role) {
61
+ case 'user':
62
+ return stableStringify(['u', normalContent(message.content)])
63
+ case 'toolResult':
64
+ return stableStringify(['r', message.toolCallId, normalContent(message.content), message.isError])
65
+ case 'system':
66
+ return stableStringify(['s', renderSystemMessageUpdate(message)])
67
+ case 'assistant': {
68
+ const blocks: JsonValue[] = []
69
+ for (const block of message.content) {
70
+ if (block.type === 'text') {
71
+ if (block.text.length > 0) blocks.push(['t', block.text])
72
+ } else if (block.type === 'thinking') {
73
+ blocks.push(['k', block.thinking, block.thinkingSignature ?? ''])
74
+ } else {
75
+ const args: JsonValue = JSON.parse(JSON.stringify(block.arguments))
76
+ blocks.push(['c', block.id, block.name, args])
77
+ }
78
+ }
79
+ return stableStringify(['a', blocks])
80
+ }
81
+ }
82
+ }
83
+
84
+ function normalContent(content: string | (TextContent | ImageContent)[]): JsonValue {
85
+ if (!Array.isArray(content)) return [['t', content]]
86
+ return content.map((block) => (block.type === 'text' ? ['t', block.text] : ['i', block.mimeType, sha(block.data)]))
87
+ }
88
+
89
+ export function toolsHash(tools: Tool[]): string {
90
+ return sha(
91
+ JSON.stringify(
92
+ tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }))
93
+ )
94
+ )
95
+ }
96
+
97
+ export function toolNameFromMcp(name: string): string {
98
+ return name.startsWith(MCP_PREFIX) ? name.slice(MCP_PREFIX.length) : name
99
+ }
100
+
101
+ export function mcpToolDefs(tools: Tool[]): McpToolDef[] {
102
+ return tools.map((tool) => {
103
+ const parameters = jsonObject(JSON.parse(JSON.stringify(tool.parameters)))
104
+ return {
105
+ name: tool.name,
106
+ description: tool.description,
107
+ inputSchema: mcpInputSchema(parameters)
108
+ }
109
+ })
110
+ }
111
+
112
+ export function toolResultMcp(message: ToolResultMessage): McpResult {
113
+ const content = message.content.map((block) =>
114
+ block.type === 'text'
115
+ ? { type: 'text' as const, text: block.text }
116
+ : { type: 'image' as const, data: block.data, mimeType: block.mimeType }
117
+ )
118
+ return {
119
+ content: content.length > 0 ? content : [{ type: 'text', text: '(no output)' }],
120
+ isError: message.isError
121
+ }
122
+ }
123
+
124
+ function imageBlock(block: ImageContent): CliBlock {
125
+ return { type: 'image', source: { type: 'base64', media_type: block.mimeType, data: block.data } }
126
+ }
127
+
128
+ function messageText(content: string | (TextContent | ImageContent)[]): string {
129
+ if (!Array.isArray(content)) return content
130
+ return content
131
+ .map((block) => (block.type === 'text' ? block.text : `[image: ${block.mimeType}]`))
132
+ .join('\n')
133
+ .trim()
134
+ }
135
+
136
+ export function userContent(messages: TurnMessage[]): CliBlock[] {
137
+ const content: CliBlock[] = []
138
+ for (const message of messages) {
139
+ if (message.role === 'user') {
140
+ if (!Array.isArray(message.content)) {
141
+ if (message.content.length > 0) content.push({ type: 'text', text: message.content })
142
+ continue
143
+ }
144
+ for (const block of message.content) {
145
+ if (block.type === 'text') {
146
+ if (block.text.length > 0) content.push({ type: 'text', text: block.text })
147
+ } else {
148
+ content.push(imageBlock(block))
149
+ }
150
+ }
151
+ continue
152
+ }
153
+ if (message.role === 'system') {
154
+ const text = renderSystemMessageUpdate(message).trim()
155
+ if (text) content.push({ type: 'text', text: `<system-reminder>\n${text}\n</system-reminder>` })
156
+ }
157
+ }
158
+ return content
159
+ }
160
+
161
+ export function replayContent(messages: TurnMessage[]): CliBlock[] {
162
+ if (messages.length === 1 && messages[0]?.role === 'user') return userContent(messages)
163
+
164
+ const parts: string[] = [
165
+ 'This is the existing Pi conversation, replayed because the transport restarted. Treat it as prior turns, not as a new request. Continue after the final entry.'
166
+ ]
167
+ for (const message of messages) {
168
+ if (message.role === 'user') {
169
+ const text = messageText(message.content)
170
+ if (text) parts.push(`USER:\n${text}`)
171
+ continue
172
+ }
173
+ if (message.role === 'toolResult') {
174
+ const text = messageText(message.content)
175
+ const status = message.isError ? 'failed' : 'ok'
176
+ parts.push(`TOOL RESULT ${message.toolName} (${status}):\n${text}`)
177
+ continue
178
+ }
179
+ if (message.role === 'system') {
180
+ const text = renderSystemMessageUpdate(message).trim()
181
+ if (text) parts.push(`SYSTEM UPDATE:\n${text}`)
182
+ continue
183
+ }
184
+ const rendered = message.content
185
+ .map((block) => {
186
+ if (block.type === 'text') return block.text
187
+ if (block.type === 'thinking') return ''
188
+ return `CALLED ${block.name}(${JSON.stringify(block.arguments)})`
189
+ })
190
+ .filter((line) => line.length > 0)
191
+ .join('\n')
192
+ if (rendered) parts.push(`ASSISTANT:\n${rendered}`)
193
+ }
194
+
195
+ const content: CliBlock[] = [{ type: 'text', text: parts.join('\n\n') }]
196
+ for (const message of messages) {
197
+ if (message.role !== 'user' || !Array.isArray(message.content)) continue
198
+ for (const block of message.content) {
199
+ if (block.type !== 'image') continue
200
+ content.push({ type: 'text', text: 'Image from an earlier user message:' })
201
+ content.push(imageBlock(block))
202
+ }
203
+ }
204
+ return content
205
+ }
206
+
207
+ export function systemPreamble(cwd: string): string {
208
+ return [
209
+ 'Your tools are MCP tools served by Pi. Call them by their declared names, which Claude Code shows as mcp__pi__<name>. No built-in tools are enabled.',
210
+ 'Pi owns the conversation, approvals, file edits, shell, and compaction. Do not claim work you have not done with a Pi tool.',
211
+ `Working directory: ${cwd}`
212
+ ].join('\n')
213
+ }