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