@sciilo.ai/codex-sidecar 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/src/bridge.js ADDED
@@ -0,0 +1,844 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { basename } from 'node:path'
4
+ import WebSocket from 'ws'
5
+ import { CodexAppServer } from './codex-app-server.js'
6
+ import { createHandoffKeypair, openSealedDek } from './vault.js'
7
+ import { DEFAULT_REASONING_EFFORT } from './config.js'
8
+ import { openText, sealArguments } from './document-seal.js'
9
+
10
+ const RECONNECT_DELAYS = [500, 1_000, 2_000, 5_000, 10_000, 30_000]
11
+ const HANDSHAKE_TIMEOUT = 10_000
12
+ const HEARTBEAT_INTERVAL = 20_000
13
+ const HEARTBEAT_TIMEOUT = 8_000
14
+ export const PAIRING_REQUIRED_CLOSE_CODE = 4001
15
+ const APPROVAL_METHODS = new Set([
16
+ 'item/commandExecution/requestApproval',
17
+ 'item/fileChange/requestApproval',
18
+ 'item/permissions/requestApproval',
19
+ 'execCommandApproval',
20
+ 'applyPatchApproval',
21
+ ])
22
+
23
+ export class SidecarBridge extends EventEmitter {
24
+ constructor(config, {
25
+ WebSocketClass = WebSocket,
26
+ codex = new CodexAppServer({
27
+ command: config.codexCommand,
28
+ cwd: config.workspace,
29
+ }),
30
+ } = {}) {
31
+ super()
32
+ this.config = config
33
+ this.WebSocketClass = WebSocketClass
34
+ this.codex = codex
35
+ this.socket = null
36
+ this.tools = []
37
+ // Server-owned, received once per connection and reused for every thread
38
+ // this process starts. Never read from disk, never written to it.
39
+ this.baseInstructions = null
40
+ this.threads = new Map()
41
+ this.activeByThread = new Map()
42
+ this.pendingTools = new Map()
43
+ this.pendingApprovals = new Map()
44
+ this.pendingInputs = new Map()
45
+ this.reconnectAttempt = 0
46
+ this.reconnectTimer = null
47
+ this.handshakeTimer = null
48
+ this.heartbeat = null
49
+ this.heartbeatTimeout = null
50
+ this.stopping = false
51
+ this.pairingRequired = false
52
+ this.context = null
53
+ this.ready = false
54
+ this.instanceId = randomUUID()
55
+ }
56
+
57
+ async start() {
58
+ this.codex.on('notification', frame => this.onCodexNotification(frame))
59
+ this.codex.on('request', frame => this.onCodexRequest(frame))
60
+ this.codex.on('log', line => this.emit('log', line))
61
+ this.codex.on('exit', error => this.emit('error', error))
62
+ await this.codex.start()
63
+ // A throwaway key pair, forged at start-up and lost on exit. The private
64
+ // half is non-extractable: it cannot be serialised, therefore it cannot be
65
+ // written to a config file even by mistake. The browser seals the vault key
66
+ // for this public half alone; Sciilo relays a block it cannot open.
67
+ this.vault = await createHandoffKeypair()
68
+ this.vaultKey = null
69
+ this.connect()
70
+ }
71
+
72
+ stop() {
73
+ this.stopping = true
74
+ clearTimeout(this.reconnectTimer)
75
+ this.clearHandshakeTimeout()
76
+ this.clearHeartbeat()
77
+ this.socket?.close()
78
+ this.socket = null
79
+ this.codex.stop()
80
+ }
81
+
82
+ connect() {
83
+ if (this.stopping || this.pairingRequired) return
84
+ if (this.socket && [this.WebSocketClass.CONNECTING, this.WebSocketClass.OPEN]
85
+ .includes(this.socket.readyState)) return
86
+ const socket = new this.WebSocketClass(sidecarWebSocketUrl(this.config.appUrl), {
87
+ headers: { Authorization: `Bearer ${this.config.connectionKey}` },
88
+ handshakeTimeout: HANDSHAKE_TIMEOUT,
89
+ })
90
+ this.socket = socket
91
+ socket.on('open', () => {
92
+ if (this.socket !== socket) return
93
+ this.ready = false
94
+ this.startHandshakeTimeout(socket)
95
+ this.startHeartbeat(socket)
96
+ })
97
+ socket.on('message', data => {
98
+ if (this.socket !== socket) return
99
+ this.markSocketAlive(socket)
100
+ try {
101
+ this.onApplicationFrame(JSON.parse(data.toString()))
102
+ } catch (error) {
103
+ this.emit('error', new Error(`Invalid application frame: ${error.message}`))
104
+ }
105
+ })
106
+ socket.on('close', (code, reason) => {
107
+ if (this.socket !== socket) return
108
+ this.clearHandshakeTimeout()
109
+ this.clearHeartbeat()
110
+ this.socket = null
111
+ this.ready = false
112
+ if (code === PAIRING_REQUIRED_CLOSE_CODE) {
113
+ this.requirePairing(reason?.toString() || 'connection_key_invalid')
114
+ }
115
+ this.emit('disconnected', {
116
+ pairingRequired: this.pairingRequired,
117
+ reason: reason?.toString() || null,
118
+ })
119
+ if (!this.pairingRequired) this.scheduleReconnect()
120
+ })
121
+ socket.on('error', error => {
122
+ this.emit('error', error)
123
+ if (this.socket === socket && socket.readyState !== this.WebSocketClass.CLOSED) {
124
+ socket.terminate?.()
125
+ }
126
+ })
127
+ }
128
+
129
+ clearHeartbeat() {
130
+ clearInterval(this.heartbeat)
131
+ clearTimeout(this.heartbeatTimeout)
132
+ this.heartbeat = null
133
+ this.heartbeatTimeout = null
134
+ }
135
+
136
+ clearHandshakeTimeout() {
137
+ clearTimeout(this.handshakeTimer)
138
+ this.handshakeTimer = null
139
+ }
140
+
141
+ startHandshakeTimeout(socket) {
142
+ this.clearHandshakeTimeout()
143
+ this.handshakeTimer = setTimeout(() => {
144
+ this.handshakeTimer = null
145
+ if (this.socket === socket && !this.ready
146
+ && socket.readyState === this.WebSocketClass.OPEN) {
147
+ socket.terminate?.()
148
+ }
149
+ }, HANDSHAKE_TIMEOUT)
150
+ }
151
+
152
+ startHeartbeat(socket) {
153
+ this.clearHeartbeat()
154
+ this.heartbeat = setInterval(() => {
155
+ if (this.socket !== socket || socket.readyState !== this.WebSocketClass.OPEN) return
156
+ if (!this.send({ type: 'ping' })) return
157
+ clearTimeout(this.heartbeatTimeout)
158
+ this.heartbeatTimeout = setTimeout(() => {
159
+ this.heartbeatTimeout = null
160
+ if (this.socket === socket && socket.readyState === this.WebSocketClass.OPEN) {
161
+ socket.terminate?.()
162
+ }
163
+ }, HEARTBEAT_TIMEOUT)
164
+ }, HEARTBEAT_INTERVAL)
165
+ }
166
+
167
+ markSocketAlive(socket) {
168
+ if (this.socket !== socket) return
169
+ clearTimeout(this.heartbeatTimeout)
170
+ this.heartbeatTimeout = null
171
+ }
172
+
173
+ scheduleReconnect() {
174
+ if (this.stopping || this.pairingRequired || this.reconnectTimer) return
175
+ const index = Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)
176
+ this.reconnectAttempt += 1
177
+ this.reconnectTimer = setTimeout(() => {
178
+ this.reconnectTimer = null
179
+ this.connect()
180
+ }, RECONNECT_DELAYS[index])
181
+ }
182
+
183
+ send(frame) {
184
+ if (this.socket?.readyState === this.WebSocketClass.OPEN) {
185
+ try {
186
+ this.socket.send(JSON.stringify(frame))
187
+ return true
188
+ } catch (error) {
189
+ this.emit('error', error)
190
+ this.socket.terminate?.()
191
+ }
192
+ }
193
+ return false
194
+ }
195
+
196
+ requirePairing(reason = 'connection_key_invalid') {
197
+ if (this.pairingRequired) return
198
+ this.pairingRequired = true
199
+ this.ready = false
200
+ clearTimeout(this.reconnectTimer)
201
+ this.reconnectTimer = null
202
+ this.clearHandshakeTimeout()
203
+ this.clearHeartbeat()
204
+ this.emit('pairingRequired', { reason })
205
+ }
206
+
207
+ updateConnectionKey(connectionKey) {
208
+ if (!connectionKey || typeof connectionKey !== 'string') {
209
+ throw new Error('The new connection key is missing.')
210
+ }
211
+ const previous = this.socket
212
+ this.socket = null
213
+ this.clearHandshakeTimeout()
214
+ this.clearHeartbeat()
215
+ previous?.close()
216
+ clearTimeout(this.reconnectTimer)
217
+ this.reconnectTimer = null
218
+ this.config = { ...this.config, connectionKey }
219
+ this.pairingRequired = false
220
+ this.ready = false
221
+ this.reconnectAttempt = 0
222
+ this.connect()
223
+ }
224
+
225
+ onApplicationFrame(frame) {
226
+ switch (frame.type) {
227
+ case 'sidecar.pairing_required':
228
+ this.requirePairing(frame.reason || 'revoked')
229
+ break
230
+ case 'sidecar.welcome':
231
+ this.clearHandshakeTimeout()
232
+ this.reconnectAttempt = 0
233
+ this.tools = frame.tools || []
234
+ this.ready = true
235
+ this.emit('connected')
236
+ this.send({
237
+ type: 'sidecar.ready',
238
+ protocol: frame.protocol,
239
+ instanceId: this.instanceId,
240
+ codex: true,
241
+ toolCount: this.tools.length,
242
+ workspace: workspaceIdentity(this.config.workspace),
243
+ vaultPublicKey: this.vault?.publicKey ?? null,
244
+ })
245
+ break
246
+ case 'vault.key':
247
+
248
+ openSealedDek(this.vault.privateKey, frame.sealed)
249
+ .then(key => {
250
+ this.vaultKey = key
251
+ this.emit('vaultUnlocked')
252
+ })
253
+ .catch(() => {
254
+ // Sealed for another sidecar, or altered in transit. Working
255
+ // without it beats working with a key we cannot trust.
256
+ this.vaultKey = null
257
+ this.emit('vaultLocked')
258
+ })
259
+ break
260
+ case 'assistant.turn':
261
+ this.startTurn(frame).catch(error => this.send({
262
+ type: 'assistant.failed',
263
+ requestId: frame.requestId,
264
+ message: error.message,
265
+ }))
266
+ break
267
+ case 'assistant.interrupt':
268
+ this.interrupt(frame).catch(error => this.emit('error', error))
269
+ break
270
+ case 'context.update':
271
+ this.context = frame.context || null
272
+ break
273
+ case 'approval.resolve':
274
+ this.resolveApproval(frame)
275
+ break
276
+ case 'input.resolve':
277
+ this.resolveInput(frame)
278
+ break
279
+ case 'interaction.sync':
280
+ this.replayPendingInteractions()
281
+ break
282
+ case 'tool.result':
283
+ this.resolveTool(frame)
284
+ break
285
+ default:
286
+ break
287
+ }
288
+ }
289
+
290
+ async startTurn(frame) {
291
+ if (!this.ready) {
292
+ throw new Error('The sidecar is still loading the application tools.')
293
+ }
294
+ // The server owns these instructions and now sends them ONCE per connection,
295
+ // not with every turn: they only ever configure a newly created thread, and
296
+ // resending thousands of words per message was both waste and one more
297
+ // chance to capture the prompt off a machine that is not ours. Keeping them
298
+ // here is what makes the second thread of a session as well-configured as
299
+ // the first. The cache dies with the process, exactly like the threads it
300
+ // configures — so the server, which tracks the same transport, sends again
301
+ // at the next connection without either side asking.
302
+ if (typeof frame.baseInstructions === 'string' && frame.baseInstructions.trim()) {
303
+ this.baseInstructions = frame.baseInstructions
304
+ }
305
+ const conversationId = frame.conversationId || randomUUID()
306
+ let threadId = this.threads.get(conversationId)
307
+ const startsNewThread = !threadId
308
+ if (!threadId) {
309
+ const threadOptions = {
310
+ cwd: this.config.workspace,
311
+ model: this.config.model,
312
+ modelProvider: this.config.modelProvider,
313
+ approvalPolicy: 'on-request',
314
+ sandbox: 'workspace-write',
315
+ dynamicTools: dynamicTools(this.tools),
316
+ // Codex settings travel in `config`, keyed as in config.toml — no
317
+ // top-level `reasoningEffort` parameter exists, and passing one is
318
+ // accepted in silence while the machine's own value keeps winning.
319
+ config: {
320
+ model_reasoning_effort:
321
+ this.config.reasoningEffort || DEFAULT_REASONING_EFFORT,
322
+ },
323
+ }
324
+ if (this.baseInstructions) {
325
+ threadOptions.baseInstructions = this.baseInstructions
326
+ }
327
+ const started = await this.codex.request('thread/start', threadOptions)
328
+ threadId = started.thread.id
329
+ this.threads.set(conversationId, threadId)
330
+ }
331
+ if (this.activeByThread.has(threadId)) {
332
+ throw new Error('A Codex turn is already running in this conversation.')
333
+ }
334
+
335
+ const state = {
336
+ requestId: frame.requestId,
337
+ conversationId,
338
+ threadId,
339
+ turnId: null,
340
+ text: '',
341
+ }
342
+ this.activeByThread.set(threadId, state)
343
+ const context = frame.context || this.context
344
+ const inputText = buildTurnInput(frame.message, {
345
+ context,
346
+ history: startsNewThread ? frame.history : null,
347
+ })
348
+ try {
349
+ const response = await this.codex.request('turn/start', {
350
+ threadId,
351
+ input: [{ type: 'text', text: inputText, text_elements: [] }],
352
+ })
353
+ state.turnId = response.turn.id
354
+ } catch (error) {
355
+ this.activeByThread.delete(threadId)
356
+ throw error
357
+ }
358
+ }
359
+
360
+ async interrupt(frame) {
361
+ const threadId = this.threads.get(frame.conversationId)
362
+ const state = threadId ? this.activeByThread.get(threadId) : null
363
+ const turnId = frame.turnId || state?.turnId
364
+ if (threadId && turnId) {
365
+ await this.codex.request('turn/interrupt', { threadId, turnId })
366
+ }
367
+ }
368
+
369
+ onCodexNotification(frame) {
370
+ const params = frame.params || {}
371
+ const state = params.threadId ? this.activeByThread.get(params.threadId) : null
372
+ const visible = publicCodexNotification(frame)
373
+ if (visible) {
374
+ this.send({
375
+ type: 'codex.event',
376
+ requestId: state?.requestId,
377
+ conversationId: state?.conversationId,
378
+ method: visible.method,
379
+ params: visible.params,
380
+ })
381
+ }
382
+ if (frame.method === 'item/agentMessage/delta' && state) {
383
+ state.text += params.delta || ''
384
+ this.send({
385
+ type: 'assistant.delta',
386
+ requestId: state.requestId,
387
+ conversationId: state.conversationId,
388
+ turnId: params.turnId,
389
+ delta: params.delta || '',
390
+ fullText: state.text,
391
+ })
392
+ } else if (frame.method === 'item/completed' && state
393
+ && params.item?.type === 'agentMessage' && params.item.text) {
394
+ state.text = params.item.text
395
+ } else if (frame.method === 'turn/completed' && state) {
396
+ const failed = params.turn?.status === 'failed'
397
+ this.send({
398
+ type: failed ? 'assistant.failed' : 'assistant.completed',
399
+ requestId: state.requestId,
400
+ conversationId: state.conversationId,
401
+ turnId: params.turn?.id,
402
+ text: state.text,
403
+ message: params.turn?.error?.message,
404
+ })
405
+ this.activeByThread.delete(params.threadId)
406
+ }
407
+ }
408
+
409
+ onCodexRequest(frame) {
410
+ if (frame.method === 'currentTime/read') {
411
+ this.codex.respond(frame.id, {
412
+ currentTimeAt: Math.floor(Date.now() / 1_000),
413
+ })
414
+ return
415
+ }
416
+ if (frame.method === 'item/tool/call') {
417
+ this.dispatchToolCall(frame)
418
+ return
419
+ }
420
+ if (frame.method === 'item/tool/requestUserInput') {
421
+ const externalId = randomUUID()
422
+ const state = this.activeStateFor(frame)
423
+ this.pendingInputs.set(externalId, {
424
+ codexId: frame.id,
425
+ turnRequestId: state?.requestId,
426
+ conversationId: state?.conversationId,
427
+ questions: frame.params?.questions || [],
428
+ })
429
+ if (!this.send(this.pendingInputFrame(externalId,
430
+ this.pendingInputs.get(externalId)))) {
431
+ this.pendingInputs.delete(externalId)
432
+ this.codex.respond(frame.id, { answers: {} })
433
+ }
434
+ return
435
+ }
436
+ if (APPROVAL_METHODS.has(frame.method)) {
437
+ const externalId = randomUUID()
438
+ const state = this.activeStateFor(frame)
439
+ this.pendingApprovals.set(externalId, {
440
+ codexId: frame.id,
441
+ method: frame.method,
442
+ params: frame.params,
443
+ turnRequestId: state?.requestId,
444
+ conversationId: state?.conversationId,
445
+ })
446
+ const sent = this.send(this.pendingApprovalFrame(externalId,
447
+ this.pendingApprovals.get(externalId)))
448
+ if (!sent) this.resolveApproval({ requestId: externalId, decision: 'decline' })
449
+ return
450
+ }
451
+ if (frame.method === 'mcpServer/elicitation/request') {
452
+ this.send({
453
+ type: 'error',
454
+ message: `MCP server ${frame.params?.serverName || ''} asked for an unsupported input.`,
455
+ })
456
+ this.codex.respond(frame.id, {
457
+ action: 'decline',
458
+ content: null,
459
+ _meta: null,
460
+ })
461
+ return
462
+ }
463
+ this.codex.respondError(frame.id, `Unsupported Codex request: ${frame.method}`, -32601)
464
+ }
465
+
466
+ /**
467
+ * Sends a tool call out, with its content sealed first.
468
+ *
469
+ * The sealed arguments are what gets remembered, not the originals: they are
470
+ * echoed back in `artifact.created`, and echoing the clear text there would
471
+ * hand the server exactly what the sealing just took away from it.
472
+ *
473
+ * Sealing failure does not cancel the call. Losing the user's work is the
474
+ * worse outcome, and the database guard reports anything readable that lands
475
+ * in storage — a silent skip here cannot pass for success there.
476
+ */
477
+ async dispatchToolCall(frame) {
478
+ const externalId = randomUUID()
479
+ const state = this.activeStateFor(frame)
480
+ let args = frame.params.arguments || {}
481
+ try {
482
+ args = await sealArguments(this.vaultKey, frame.params.tool, args)
483
+ } catch (failure) {
484
+ this.emit('vaultSealFailed', { tool: frame.params.tool, reason: failure.message })
485
+ }
486
+ this.pendingTools.set(externalId, {
487
+ codexId: frame.id,
488
+ tool: frame.params.tool,
489
+ arguments: args,
490
+ turnRequestId: state?.requestId,
491
+ conversationId: state?.conversationId,
492
+ })
493
+ if (!this.send({
494
+ type: 'tool.call',
495
+ requestId: externalId,
496
+ tool: frame.params.tool,
497
+ arguments: args,
498
+ })) {
499
+ this.pendingTools.delete(externalId)
500
+ this.codex.respond(frame.id, {
501
+ contentItems: [{ type: 'inputText', text: 'The application is disconnected.' }],
502
+ success: false,
503
+ })
504
+ }
505
+ }
506
+
507
+ activeStateFor(frame) {
508
+ return frame.params?.threadId
509
+ ? this.activeByThread.get(frame.params.threadId)
510
+ : this.activeByThread.size === 1
511
+ ? this.activeByThread.values().next().value
512
+ : null
513
+ }
514
+
515
+ pendingApprovalFrame(requestId, pending) {
516
+ return {
517
+ type: 'approval.request',
518
+ requestId,
519
+ turnRequestId: pending?.turnRequestId,
520
+ conversationId: pending?.conversationId,
521
+ kind: approvalKind(pending?.method || ''),
522
+ title: approvalTitle(pending?.method || ''),
523
+ command: pending?.params?.command,
524
+ reason: pending?.params?.reason,
525
+ detail: pending?.params?.command || pending?.params?.reason,
526
+ }
527
+ }
528
+
529
+ pendingInputFrame(requestId, pending) {
530
+ return {
531
+ type: 'input.request',
532
+ requestId,
533
+ turnRequestId: pending?.turnRequestId,
534
+ conversationId: pending?.conversationId,
535
+ questions: pending?.questions || [],
536
+ }
537
+ }
538
+
539
+ replayPendingInteractions() {
540
+ for (const [requestId, pending] of this.pendingApprovals) {
541
+ this.send(this.pendingApprovalFrame(requestId, pending))
542
+ }
543
+ for (const [requestId, pending] of this.pendingInputs) {
544
+ this.send(this.pendingInputFrame(requestId, pending))
545
+ }
546
+ }
547
+
548
+ async resolveTool(frame) {
549
+ const pending = this.pendingTools.get(frame.requestId)
550
+ if (!pending) return
551
+ this.pendingTools.delete(frame.requestId)
552
+ // The other half of the boundary. A read tool now answers with ciphertext
553
+ // where the document body used to be; the agent has to receive the body.
554
+ // Without this, sealing would not make the project private — it would make
555
+ // it incomprehensible to the one writing it.
556
+ let content = frame.content || ''
557
+ try {
558
+ content = await openText(this.vaultKey, content)
559
+ } catch (failure) {
560
+ this.emit('vaultOpenFailed', { tool: pending.tool, reason: failure.message })
561
+ }
562
+ this.codex.respond(pending.codexId, {
563
+ contentItems: [{ type: 'inputText', text: content }],
564
+ success: Boolean(frame.success),
565
+ })
566
+ const result = parseToolContent(frame.content)
567
+ this.send({
568
+ type: 'artifact.created',
569
+ requestId: frame.requestId,
570
+ tool: pending.tool,
571
+ arguments: pending.arguments,
572
+ result,
573
+ success: Boolean(frame.success),
574
+ turnRequestId: pending.turnRequestId,
575
+ conversationId: pending.conversationId,
576
+ })
577
+ }
578
+
579
+ resolveApproval(frame) {
580
+ const pending = this.pendingApprovals.get(frame.requestId)
581
+ if (!pending) return
582
+ this.pendingApprovals.delete(frame.requestId)
583
+ const accepted = frame.decision === 'accept'
584
+ const legacy = pending.method === 'execCommandApproval'
585
+ || pending.method === 'applyPatchApproval'
586
+ if (pending.method === 'item/permissions/requestApproval') {
587
+ this.codex.respond(pending.codexId, {
588
+ permissions: accepted ? compactPermissions(pending.params?.permissions) : {},
589
+ scope: 'turn',
590
+ })
591
+ return
592
+ }
593
+ this.codex.respond(pending.codexId, {
594
+ decision: legacy
595
+ ? accepted ? 'approved' : { denied: { rejection: 'Declined by the user.' } }
596
+ : accepted ? 'accept' : 'decline',
597
+ })
598
+ }
599
+
600
+ resolveInput(frame) {
601
+ const pending = this.pendingInputs.get(frame.requestId)
602
+ if (!pending) return
603
+ this.pendingInputs.delete(frame.requestId)
604
+ this.codex.respond(pending.codexId, { answers: frame.answers || {} })
605
+ }
606
+ }
607
+
608
+ export function workspaceIdentity(workspace) {
609
+ const normalized = String(workspace || '')
610
+ return {
611
+ name: basename(normalized) || 'Project',
612
+ fingerprint: createHash('sha256').update(normalized).digest('hex'),
613
+ }
614
+ }
615
+
616
+ export function buildTurnInput(message, { context = null, history = null } = {}) {
617
+ const blocks = []
618
+ if (Array.isArray(history) && history.length) {
619
+ const previous = history
620
+ .filter(entry => ['user', 'assistant'].includes(entry?.role)
621
+ && typeof entry.text === 'string' && entry.text.trim())
622
+ .map(entry => ({ role: entry.role, text: entry.text }))
623
+ if (previous.length) {
624
+ blocks.push(`<conversation_history>${JSON.stringify(previous)}</conversation_history>`)
625
+ }
626
+ }
627
+ blocks.push(String(message || ''))
628
+ if (context) {
629
+ blocks.push(`<application_context>${JSON.stringify(context)}</application_context>`)
630
+ }
631
+ return blocks.join('\n\n')
632
+ }
633
+
634
+ export function sidecarWebSocketUrl(appUrl) {
635
+ const url = new URL(appUrl)
636
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
637
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/api/sidecar/connect`
638
+ url.search = ''
639
+ url.hash = ''
640
+ return url.toString()
641
+ }
642
+
643
+ export function dynamicTools(tools) {
644
+ return tools.map(tool => ({
645
+ type: 'function',
646
+ name: tool.name,
647
+ description: tool.description,
648
+ inputSchema: tool.inputSchema,
649
+ }))
650
+ }
651
+
652
+ export function parseToolContent(content) {
653
+ if (typeof content !== 'string') return content
654
+ try {
655
+ return JSON.parse(content)
656
+ } catch {
657
+ const result = { message: content }
658
+ const documentId = content.match(
659
+ /\bid=([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\b/i,
660
+ )?.[1]
661
+ if (documentId) result.document_id = documentId
662
+ return result
663
+ }
664
+ }
665
+
666
+ export function publicCodexNotification(frame) {
667
+ const method = frame?.method
668
+ if (!method) return null
669
+ if (method === 'turn/started' || method === 'turn/completed') {
670
+ return { method, params: publicTurnParams(frame.params) }
671
+ }
672
+ if (method === 'item/started' || method === 'item/completed') {
673
+ const item = publicCodexItem(frame.params?.item)
674
+ return item ? {
675
+ method,
676
+ params: {
677
+ threadId: frame.params?.threadId,
678
+ turnId: frame.params?.turnId,
679
+ item,
680
+ },
681
+ } : null
682
+ }
683
+ if (method === 'warning' || method === 'configWarning' || method === 'error') {
684
+ return {
685
+ method,
686
+ params: { message: compactPublicText(frame.params?.message
687
+ || frame.params?.summary || frame.params?.error) },
688
+ }
689
+ }
690
+ if (method === 'model/rerouted') {
691
+ return {
692
+ method,
693
+ params: {
694
+ fromModel: compactPublicText(frame.params?.fromModel, 80),
695
+ toModel: compactPublicText(frame.params?.toModel, 80),
696
+ },
697
+ }
698
+ }
699
+ return null
700
+ }
701
+
702
+ function publicTurnParams(params = {}) {
703
+ const turn = params.turn || {}
704
+ return {
705
+ threadId: params.threadId,
706
+ turn: {
707
+ id: turn.id,
708
+ status: turn.status,
709
+ },
710
+ }
711
+ }
712
+
713
+ function publicCodexItem(item) {
714
+ if (!item || ['userMessage', 'agentMessage'].includes(item.type)) return null
715
+ const summary = {
716
+ id: item.id,
717
+ type: item.type,
718
+ status: item.status,
719
+ }
720
+ switch (item.type) {
721
+ case 'reasoning':
722
+ case 'plan':
723
+ case 'contextCompaction':
724
+ case 'enteredReviewMode':
725
+ case 'exitedReviewMode':
726
+ return summary
727
+ case 'commandExecution':
728
+ return { ...summary, ...commandActivity(item.command) }
729
+ case 'fileChange':
730
+ return { ...summary, ...publicFileChanges(item.changes) }
731
+ case 'dynamicToolCall':
732
+ return {
733
+ ...summary,
734
+ tool: compactPublicText(item.tool, 120),
735
+ subject: publicToolSubject(item.arguments),
736
+ success: item.success,
737
+ }
738
+ case 'mcpToolCall':
739
+ case 'collabToolCall':
740
+ return {
741
+ ...summary,
742
+ tool: compactPublicText(item.tool, 120),
743
+ status: item.error ? 'failed' : item.status,
744
+ }
745
+ case 'webSearch':
746
+ return { ...summary, subject: compactPublicText(item.query, 140) }
747
+ case 'imageView':
748
+ return summary
749
+ default:
750
+ return null
751
+ }
752
+ }
753
+
754
+ function commandActivity(command) {
755
+ const text = (Array.isArray(command) ? command.join(' ') : String(command || '')).trim()
756
+ if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test(?::[\w.-]+)?\b/i.test(text)
757
+ || /(?:^|[\s;&|])(?:node\s+--test|pytest|playwright\s+test|cargo\s+test|go\s+test)\b/i.test(text)
758
+ || /(?:^|[\s;&|])(?:mvn|\.\/gradlew|gradle)\b[^;&|]*\btest\b/i.test(text)) {
759
+ return { activity: 'verify', detail: 'tests' }
760
+ }
761
+ if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+run\s+(?:build|package)\b/i.test(text)
762
+ || /(?:^|[\s;&|])(?:vite\s+build|cargo\s+build)\b/i.test(text)
763
+ || /(?:^|[\s;&|])(?:mvn|\.\/gradlew|gradle)\b[^;&|]*\b(?:build|compile|package)\b/i.test(text)) {
764
+ return { activity: 'verify', detail: 'build' }
765
+ }
766
+ if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+run\s+(?:lint|format)\b/i.test(text)
767
+ || /(?:^|[\s;&|])(?:eslint|prettier)\b/i.test(text)) {
768
+ return { activity: 'verify', detail: 'lint' }
769
+ }
770
+ if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+run\s+typecheck\b/i.test(text)
771
+ || /(?:^|[\s;&|])tsc\b/i.test(text)) {
772
+ return { activity: 'verify', detail: 'types' }
773
+ }
774
+ if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+run\s+(?:verify|check)\b/i.test(text)
775
+ || /(?:^|[\s;&|])(?:cargo\s+check|mvn\b[^;&|]*\bverify\b)/i.test(text)) {
776
+ return { activity: 'verify', detail: 'checks' }
777
+ }
778
+ if (/\bgit\s+(?:status|diff|log|show)\b/i.test(text)) {
779
+ return { activity: 'inspect', detail: 'git' }
780
+ }
781
+ if (/(?:^|[\s;&|])(?:rg|grep)\b/i.test(text)) {
782
+ return { activity: 'inspect', detail: 'codeSearch' }
783
+ }
784
+ if (/(?:^|[\s;&|])(?:sed|cat|head|tail)\b/i.test(text)) {
785
+ return { activity: 'inspect', detail: 'files' }
786
+ }
787
+ if (/(?:^|[\s;&|])(?:find|ls|pwd)\b/i.test(text)) {
788
+ return { activity: 'inspect', detail: 'structure' }
789
+ }
790
+ if (/(?:^|[\s;&|])(?:ps|ss)\b/i.test(text)) {
791
+ return { activity: 'inspect', detail: 'services' }
792
+ }
793
+ return { activity: 'command', detail: 'localCommand' }
794
+ }
795
+
796
+ function publicFileChanges(changes) {
797
+ const list = Array.isArray(changes) ? changes : []
798
+ const files = [...new Set(list.map(change => publicFileName(change?.path)).filter(Boolean))]
799
+ .slice(0, 4)
800
+ return { changeCount: list.length, files }
801
+ }
802
+
803
+ function publicFileName(path) {
804
+ const segments = String(path || '').replaceAll('\\', '/').split('/').filter(Boolean)
805
+ return compactPublicText(segments.at(-1), 100)
806
+ }
807
+
808
+ function publicToolSubject(args = {}) {
809
+ for (const key of ['title', 'name', 'query', 'term']) {
810
+ if (typeof args?.[key] === 'string' && args[key].trim()) {
811
+ return compactPublicText(args[key].replace(/\s+/g, ' ').trim(), 140)
812
+ }
813
+ }
814
+ return ''
815
+ }
816
+
817
+ function compactPublicText(value, limit = 320) {
818
+ const text = typeof value === 'string'
819
+ ? value
820
+ : value == null ? '' : String(value?.message || value)
821
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text
822
+ }
823
+
824
+ function approvalKind(method) {
825
+ if (method === 'item/permissions/requestApproval') return 'permissions'
826
+ if (method.includes('fileChange') || method === 'applyPatchApproval') return 'file-change'
827
+ return 'command'
828
+ }
829
+
830
+ function approvalTitle(method) {
831
+ if (method === 'item/permissions/requestApproval') {
832
+ return 'Allow additional permissions'
833
+ }
834
+ if (method.includes('fileChange') || method === 'applyPatchApproval') {
835
+ return 'Allow file changes'
836
+ }
837
+ return 'Allow the command to run'
838
+ }
839
+
840
+ function compactPermissions(permissions = {}) {
841
+ return Object.fromEntries(
842
+ Object.entries(permissions).filter(([, value]) => value != null),
843
+ )
844
+ }