Compare commits

..

6 Commits

Author SHA1 Message Date
claude-code-best
1058b7e643 feat: 修复 Codex 模型映射并添加登录后模型配置面板
- configs.ts: 将 codex 字段从 Anthropic 模型名改为实际 OpenAI 模型名
  (opus→gpt-5.4, sonnet→gpt-5.4-mini, haiku→gpt-5.4-mini, opus47→gpt-5.5)
- modelMapping.ts: 移除不存在的 gpt-5.4-nano,修复 haiku 映射,添加 opus47
- ConsoleOAuthFlow.tsx: OAuth 成功后显示模型配置面板,可编辑三种模型名称
- 已登录用户再次选择 Codex 时跳过 OAuth 直接进入模型配置
- Ctrl+R 快捷键清除登录状态并重新 OAuth
- modelOptions.ts: codex provider 支持 CODEX_DEFAULT_*_MODEL 环境变量

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 23:38:56 +08:00
claude-code-best
d091dd8bae feat: 添加 Codex 模型 provider 完整实现
- 新增 codex API 客户端、流适配、消息/工具转换、模型映射
- 支持 CODEX_API_KEY 和 CODEX_ACCESS_TOKEN 双认证 fallback
- 集成到 claude.ts 调度链和 Langfuse 可观测性
- 包含模型映射单元测试(16 cases)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:48:17 +08:00
claude-code-best
4427a6c6db feat: 注册 codex modelType 并添加 /provider codex 切换
- providers.ts: 添加 codex 到 APIProvider 类型和路由
- provider.ts: /provider codex 切换,含 CODEX_API_KEY 检查
- configs.ts: 所有 12 个模型配置添加 codex 字段
- status.tsx: 状态栏显示 Codex API
- managedEnvConstants.ts: 注册 CODEX_* 环境变量

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:23:09 +08:00
claude-code-best
13799b5058 fix: 将 modelType 从 openai-responses 改为 codex 并注册枚举值
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:06:34 +08:00
claude-code-best
cd59a88d44 feat: 集成 ChatGPT OAuth 订阅登录到 /login UI
添加 Codex ChatGPT 菜单项、OAuth 等待界面、手动 code 输入支持。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:58:30 +08:00
claude-code-best
bc4a2f1281 feat: 添加 ChatGPT OAuth 订阅登录流程
基于 OpenAI Codex CLI 官方实现,支持 PKCE 流程和手动 code 输入。
API key 交换为非致命步骤,兼容无 organization 的个人账户。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:49:42 +08:00
71 changed files with 3545 additions and 4663 deletions

View File

@@ -55,8 +55,6 @@ ccb update # 更新到最新版本
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
```
> **安装/更新失败?** 先 `npm rm -g claude-code-best` 清理旧版本,再 `npm i -g claude-code-best@latest`。仍失败则指定版本号:`npm i -g claude-code-best@<版本号>`
## ⚡ 快速开始(源码版)
### ⚙️ 环境要求

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.10.7",
"version": "1.10.4",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",

View File

@@ -61,3 +61,10 @@ export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js'
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
// Codex provider utilities
export { normalizeCodexCallId, resolveCodexCallId, createCodexFallbackCallId } from './providers/codex/callIds.js'
export { resolveCodexModel, resolveCodexMaxTokens } from './providers/codex/modelMapping.js'
export { anthropicMessagesToCodexInput } from './providers/codex/convertMessages.js'
export type { CodexImageConversionOptions } from './providers/codex/convertMessages.js'
export { anthropicToolsToCodex } from './providers/codex/convertTools.js'

View File

@@ -0,0 +1,94 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { resolveCodexModel } from '../modelMapping.js'
describe('resolveCodexModel', () => {
const originalEnv = {
CODEX_MODEL: process.env.CODEX_MODEL,
CODEX_DEFAULT_HAIKU_MODEL: process.env.CODEX_DEFAULT_HAIKU_MODEL,
CODEX_DEFAULT_SONNET_MODEL: process.env.CODEX_DEFAULT_SONNET_MODEL,
CODEX_DEFAULT_OPUS_MODEL: process.env.CODEX_DEFAULT_OPUS_MODEL,
}
beforeEach(() => {
delete process.env.CODEX_MODEL
delete process.env.CODEX_DEFAULT_HAIKU_MODEL
delete process.env.CODEX_DEFAULT_SONNET_MODEL
delete process.env.CODEX_DEFAULT_OPUS_MODEL
})
afterEach(() => {
Object.assign(process.env, originalEnv)
})
test('CODEX_MODEL env var overrides all', () => {
process.env.CODEX_MODEL = 'my-custom-model'
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('my-custom-model')
})
test('CODEX_DEFAULT_SONNET_MODEL overrides default map', () => {
process.env.CODEX_DEFAULT_SONNET_MODEL = 'my-sonnet'
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('my-sonnet')
})
test('CODEX_DEFAULT_HAIKU_MODEL overrides default map', () => {
process.env.CODEX_DEFAULT_HAIKU_MODEL = 'my-haiku'
expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('my-haiku')
})
test('CODEX_DEFAULT_OPUS_MODEL overrides default map', () => {
process.env.CODEX_DEFAULT_OPUS_MODEL = 'my-opus'
expect(resolveCodexModel('claude-opus-4-6')).toBe('my-opus')
})
test('maps known sonnet model via DEFAULT_MODEL_MAP', () => {
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('gpt-5.4-mini')
})
test('maps known haiku model via DEFAULT_MODEL_MAP', () => {
expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('gpt-5.4-mini')
})
test('maps known opus model via DEFAULT_MODEL_MAP', () => {
expect(resolveCodexModel('claude-opus-4-6')).toBe('gpt-5.4')
})
test('maps legacy sonnet models', () => {
expect(resolveCodexModel('claude-sonnet-4-20250514')).toBe('gpt-5.4-mini')
expect(resolveCodexModel('claude-3-5-sonnet-20241022')).toBe('gpt-5.4-mini')
})
test('maps legacy haiku models', () => {
expect(resolveCodexModel('claude-3-5-haiku-20241022')).toBe('gpt-5.4-mini')
})
test('maps legacy opus models', () => {
expect(resolveCodexModel('claude-opus-4-20250514')).toBe('gpt-5.4')
expect(resolveCodexModel('claude-opus-4-5-20251101')).toBe('gpt-5.4')
})
test('uses family default for unrecognized haiku model', () => {
expect(resolveCodexModel('claude-haiku-99')).toBe('gpt-5.4-mini')
})
test('uses family default for unrecognized sonnet model', () => {
expect(resolveCodexModel('claude-sonnet-99')).toBe('gpt-5.4-mini')
})
test('uses family default for unrecognized opus model', () => {
expect(resolveCodexModel('claude-opus-99')).toBe('gpt-5.4')
})
test('passes through unknown model name without family', () => {
expect(resolveCodexModel('some-random-model')).toBe('some-random-model')
})
test('strips [1m] suffix', () => {
expect(resolveCodexModel('claude-sonnet-4-6[1m]')).toBe('gpt-5.4-mini')
})
test('CODEX_MODEL takes precedence over family-specific vars', () => {
process.env.CODEX_MODEL = 'global-override'
process.env.CODEX_DEFAULT_SONNET_MODEL = 'family-override'
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('global-override')
})
})

View File

@@ -0,0 +1,31 @@
import { createHash } from 'crypto'
const MAX_CODEX_CALL_ID_LENGTH = 96
export function normalizeCodexCallId(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const sanitized = value
.trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9._:-]/g, '_')
.replace(/_+/g, '_')
.slice(0, MAX_CODEX_CALL_ID_LENGTH)
return sanitized.length > 0 ? sanitized : null
}
export function createCodexFallbackCallId(seed: string): string {
const hash = createHash('sha1')
.update(seed.length > 0 ? seed : 'codex-call')
.digest('hex')
.slice(0, 24)
return `call_${hash}`
}
export function resolveCodexCallId(value: unknown, seed: string): string {
return normalizeCodexCallId(value) ?? createCodexFallbackCallId(seed)
}

View File

@@ -0,0 +1,392 @@
import type {
ResponseFunctionToolCallOutputItem,
ResponseInputImage,
ResponseInputItem,
ResponseInputText,
} from 'openai/resources/responses/responses.mjs'
import type { Message } from '../../types/index.js'
import {
normalizeCodexCallId,
resolveCodexCallId,
} from './callIds.js'
type ContentBlock = {
type: string
text?: string
source?: {
type?: string
data?: string
media_type?: string
url?: string
}
}
type ToolUseLikeBlock = {
type: 'tool_use'
id: string
name: string
input: unknown
}
type ToolResultLikeBlock = {
type: 'tool_result'
tool_use_id: string
content?: string | ReadonlyArray<ContentBlock>
}
export type CodexImageConversionOptions = {
resolveBase64ImageUrl?: (
data: string,
mediaType?: string,
) => Promise<string | null>
}
type CodexCallIdState = {
byOriginalId: Map<string, string>
sequence: number
}
function createInputText(text: string): ResponseInputText {
return {
type: 'input_text',
text,
}
}
function createInputImage(imageUrl: string): ResponseInputImage {
return {
type: 'input_image',
image_url: imageUrl,
detail: 'high',
}
}
function getUnsupportedBlockText(type: string): string | null {
switch (type) {
case 'image':
return '[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]'
case 'document':
return '[Document omitted: codex gateway does not support document replay.]'
default:
return null
}
}
function getImageUrl(block: ContentBlock): string | null {
const source = block.source
if (!source) {
return null
}
if (source.type === 'url' && typeof source.url === 'string' && source.url.length > 0) {
return source.url
}
return null
}
async function resolveImageUrl(
block: ContentBlock,
options: CodexImageConversionOptions,
): Promise<string | null> {
const directUrl = getImageUrl(block)
if (directUrl) {
return directUrl
}
if (block.source?.type !== 'base64') {
return null
}
if (options.resolveBase64ImageUrl && typeof block.source.data === 'string') {
const uploadedUrl = await options.resolveBase64ImageUrl(
block.source.data,
block.source.media_type,
)
if (uploadedUrl) {
return uploadedUrl
}
}
return null
}
async function convertBlocksToInputContent(
content: ReadonlyArray<ContentBlock>,
options: CodexImageConversionOptions,
): Promise<Array<ResponseInputText | ResponseInputImage>> {
const output: Array<ResponseInputText | ResponseInputImage> = []
for (const block of content) {
if (block.type === 'text' && block.text) {
output.push(createInputText(block.text))
continue
}
if (block.type === 'image') {
const imageUrl = await resolveImageUrl(block, options)
if (imageUrl) {
output.push(createInputImage(imageUrl))
continue
}
}
const fallback = getUnsupportedBlockText(block.type)
if (fallback) {
output.push(createInputText(fallback))
}
}
return output
}
async function convertToolResultOutput(
content: string | ReadonlyArray<ContentBlock> | undefined,
options: CodexImageConversionOptions,
): Promise<ResponseFunctionToolCallOutputItem['output']> {
if (!content) {
return ''
}
if (typeof content === 'string') {
return content
}
const output = await convertBlocksToInputContent(content, options)
if (output.length === 0) {
return ''
}
if (output.length === 1 && output[0].type === 'input_text') {
return output[0].text
}
return output
}
function pushUserMessage(
items: ResponseInputItem[],
textParts: string[],
imageUrls: string[] = [],
): void {
const text = textParts.join('\n').trim()
if (text.length === 0 && imageUrls.length === 0) {
return
}
items.push({
type: 'message',
role: 'user',
content: [
...(text.length > 0 ? [createInputText(text)] : []),
...imageUrls.map(createInputImage),
],
} as unknown as ResponseInputItem)
}
function pushAssistantMessage(
items: ResponseInputItem[],
textParts: string[],
): void {
const text = textParts.join('\n').trim()
if (text.length === 0) {
return
}
items.push({
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text,
annotations: [],
},
],
} as unknown as ResponseInputItem)
}
function stringifyToolInput(input: unknown): string {
if (typeof input === 'string') {
return input
}
try {
return JSON.stringify(input ?? {})
} catch {
return '{}'
}
}
function createCodexCallIdState(): CodexCallIdState {
return {
byOriginalId: new Map(),
sequence: 0,
}
}
function resolveAssistantCallId(
block: ToolUseLikeBlock,
state: CodexCallIdState,
): string {
const originalId = typeof block.id === 'string' ? block.id : ''
const seed = `${block.name}:${stringifyToolInput(block.input)}:${state.sequence}`
const callId = resolveCodexCallId(originalId, seed)
if (originalId.length > 0) {
state.byOriginalId.set(originalId, callId)
}
state.sequence += 1
return callId
}
function resolveToolResultCallId(
toolUseId: unknown,
state: CodexCallIdState,
): string | null {
if (typeof toolUseId !== 'string') {
return null
}
return state.byOriginalId.get(toolUseId) ?? normalizeCodexCallId(toolUseId)
}
async function convertUserContentToInputItems(
items: ResponseInputItem[],
content: ReadonlyArray<string | ContentBlock>,
options: CodexImageConversionOptions,
callIdState: CodexCallIdState,
): Promise<void> {
const textParts: string[] = []
const imageUrls: string[] = []
for (const block of content) {
if (typeof block === 'string') {
textParts.push(block)
continue
}
if (block.type === 'tool_result') {
pushUserMessage(items, textParts, imageUrls)
textParts.length = 0
imageUrls.length = 0
const toolResultBlock = block as ToolResultLikeBlock
const callId = resolveToolResultCallId(
toolResultBlock.tool_use_id,
callIdState,
)
if (!callId) {
continue
}
items.push({
type: 'function_call_output',
call_id: callId,
output: await convertToolResultOutput(toolResultBlock.content, options),
})
continue
}
if (block.type === 'text' && block.text) {
textParts.push(block.text)
continue
}
if (block.type === 'image') {
const imageUrl = await resolveImageUrl(block, options)
if (imageUrl) {
imageUrls.push(imageUrl)
continue
}
}
const fallback = getUnsupportedBlockText(block.type)
if (fallback) {
textParts.push(fallback)
}
}
pushUserMessage(items, textParts, imageUrls)
}
function convertAssistantContentToInputItems(
items: ResponseInputItem[],
content: ReadonlyArray<string | ContentBlock>,
callIdState: CodexCallIdState,
): void {
const textParts: string[] = []
for (const block of content) {
if (typeof block === 'string') {
textParts.push(block)
continue
}
if (block.type === 'tool_use') {
pushAssistantMessage(items, textParts)
textParts.length = 0
const toolUseBlock = block as unknown as ToolUseLikeBlock
items.push({
type: 'function_call',
call_id: resolveAssistantCallId(toolUseBlock, callIdState),
name: toolUseBlock.name,
arguments: stringifyToolInput(toolUseBlock.input),
})
continue
}
if (block.type === 'text' && block.text) {
textParts.push(block.text)
}
}
pushAssistantMessage(items, textParts)
}
export async function anthropicMessagesToCodexInput(
messages: Message[],
options: CodexImageConversionOptions = {},
): Promise<ResponseInputItem[]> {
const items: ResponseInputItem[] = []
const callIdState = createCodexCallIdState()
for (const message of messages) {
if (message.type !== 'user' && message.type !== 'assistant') {
continue
}
const apiMessage = message.message
if (!apiMessage?.content) {
continue
}
if (typeof apiMessage.content === 'string') {
if (message.type === 'user') {
pushUserMessage(items, [apiMessage.content])
} else {
pushAssistantMessage(items, [apiMessage.content])
}
continue
}
if (message.type === 'user') {
await convertUserContentToInputItems(
items,
apiMessage.content as ReadonlyArray<string | ContentBlock>,
options,
callIdState,
)
} else {
convertAssistantContentToInputItems(
items,
apiMessage.content as ReadonlyArray<string | ContentBlock>,
callIdState,
)
}
}
return items
}

View File

@@ -0,0 +1,39 @@
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { Tool as CodexTool } from 'openai/resources/responses/responses.mjs'
function isClientFunctionTool(
tool: BetaToolUnion,
): tool is BetaToolUnion & {
name: string
description?: string
input_schema?: { [key: string]: unknown }
strict?: boolean
defer_loading?: boolean
} {
const value = tool as unknown as Record<string, unknown>
return typeof value.name === 'string'
}
export function anthropicToolsToCodex(
tools: BetaToolUnion[],
): CodexTool[] {
return tools.flatMap(tool => {
const value = tool as unknown as Record<string, unknown>
if (
value.type === 'advisor_20260301' ||
value.type === 'computer_20250124' ||
!isClientFunctionTool(tool)
) {
return []
}
return [{
type: 'function',
name: tool.name,
description: tool.description,
parameters: tool.input_schema ?? {},
strict: tool.strict ?? null,
...(tool.defer_loading && { defer_loading: true }),
}]
})
}

View File

@@ -0,0 +1,86 @@
/**
* Default mapping from Anthropic model names to Codex (OpenAI Responses API) model names.
* Used only when CODEX_DEFAULT_{FAMILY}_MODEL env vars are not set.
*/
const DEFAULT_MODEL_MAP: Record<string, string> = {
'claude-sonnet-4-20250514': 'gpt-5.4-mini',
'claude-sonnet-4-5-20250929': 'gpt-5.4-mini',
'claude-sonnet-4-6': 'gpt-5.4-mini',
'claude-3-7-sonnet-20250219': 'gpt-5.4-mini',
'claude-3-5-sonnet-20241022': 'gpt-5.4-mini',
'claude-opus-4-20250514': 'gpt-5.4',
'claude-opus-4-1-20250805': 'gpt-5.4',
'claude-opus-4-5-20251101': 'gpt-5.4',
'claude-opus-4-6': 'gpt-5.4',
'claude-opus-4-7': 'gpt-5.5',
'claude-haiku-4-5-20251001': 'gpt-5.4-mini',
'claude-3-5-haiku-20241022': 'gpt-5.4-mini',
}
/**
* Default model for each family when an exact match is not in DEFAULT_MODEL_MAP.
*/
const DEFAULT_FAMILY_MAP: Record<string, string> = {
haiku: 'gpt-5.4-mini',
sonnet: 'gpt-5.4-mini',
opus: 'gpt-5.4',
}
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
if (/haiku/i.test(model)) return 'haiku'
if (/opus/i.test(model)) return 'opus'
if (/sonnet/i.test(model)) return 'sonnet'
return null
}
/**
* Resolve the Codex (OpenAI Responses API) model name for a given Anthropic model.
*
* Priority:
* 1. CODEX_MODEL env var (override all)
* 2. CODEX_DEFAULT_{FAMILY}_MODEL env var (e.g. CODEX_DEFAULT_SONNET_MODEL)
* 3. DEFAULT_MODEL_MAP lookup (exact Anthropic model name match)
* 4. DEFAULT_FAMILY_MAP lookup (family-based default)
* 5. Pass through original model name
*/
export function resolveCodexModel(model: string): string {
if (process.env.CODEX_MODEL) {
return process.env.CODEX_MODEL
}
const cleanModel = model.replace(/\[1m\]$/, '')
const family = getModelFamily(cleanModel)
if (family) {
const familyOverride = process.env[`CODEX_DEFAULT_${family.toUpperCase()}_MODEL`]
if (familyOverride) {
return familyOverride
}
}
const mapped = DEFAULT_MODEL_MAP[cleanModel]
if (mapped) {
return mapped
}
if (family) {
return DEFAULT_FAMILY_MAP[family]
}
return cleanModel
}
export function resolveCodexMaxTokens(
upperLimit: number,
maxOutputTokensOverride?: number,
): number {
return (
maxOutputTokensOverride ??
(process.env.CODEX_MAX_TOKENS
? parseInt(process.env.CODEX_MAX_TOKENS, 10) || undefined
: undefined) ??
(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined
: undefined) ??
upperLimit
)
}

View File

@@ -1,180 +0,0 @@
import { describe, expect, test } from 'bun:test'
import type { Message } from 'src/types/message.js'
import { filterIncompleteToolCalls } from '../filterIncompleteToolCalls.js'
describe('filterIncompleteToolCalls', () => {
test('drops assistant tool uses that do not have matching results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: { role: 'user', content: 'continue' },
},
] as unknown as Message[]
expect(
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
).toEqual(['u1'])
})
test('preserves assistant text when dropping orphan tool uses', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'I will read the file.' },
{ type: 'tool_use', id: 'missing', name: 'Read' },
],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered).toHaveLength(1)
const first = filtered[0]!
const content = first.message!.content
expect(
Array.isArray(content) ? content.map(block => block.type) : [],
).toEqual(['text'])
})
test('keeps completed parallel tool calls when dropping an orphan', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 'done', name: 'Read' },
{ type: 'tool_use', id: 'missing', name: 'Grep' },
],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
const first = filtered[0]!
const content = first.message!.content
expect(
Array.isArray(content)
? content.map(block =>
block.type === 'tool_use' ? block.id : block.type,
)
: [],
).toEqual(['done'])
})
test('keeps assistant tool uses that have matching results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'done', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
},
},
] as unknown as Message[]
expect(
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
).toEqual(['a1', 'u1'])
})
test('drops orphan tool results when their tool use was removed', () => {
const messages = [
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
],
},
},
] as unknown as Message[]
expect(filterIncompleteToolCalls(messages)).toEqual([])
})
test('keeps user text while dropping orphan tool results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: { role: 'assistant', content: 'done' },
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [
{ type: 'text', text: 'keep this' },
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
const content = filtered[1]!.message!.content
expect(Array.isArray(content) ? content : []).toEqual([
{ type: 'text', text: 'keep this' },
])
})
test('drops malformed tool blocks without ids', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', content: 'late' }],
},
},
] as unknown as Message[]
expect(filterIncompleteToolCalls(messages)).toEqual([])
})
})

View File

@@ -1,110 +0,0 @@
import type {
AssistantMessage,
Message,
UserMessage,
} from 'src/types/message.js'
/**
* Removes invalid or orphaned tool_use/tool_result blocks while preserving
* completed tool-call pairs. This is intentionally block-level, not
* message-level, so completed parallel tool calls stay paired with results.
*/
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
const toolUseIdsWithResults = new Set<string>()
for (const message of messages) {
if (message?.type === 'user') {
const userMessage = message as UserMessage
const content = userMessage.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && block.tool_use_id) {
toolUseIdsWithResults.add(block.tool_use_id)
}
}
}
}
}
const retainedToolUseIds = new Set<string>()
const withoutOrphanToolUses: Message[] = []
for (const message of messages) {
if (message?.type === 'assistant') {
const assistantMessage = message as AssistantMessage
const content = assistantMessage.message.content
if (Array.isArray(content)) {
let changed = false
const filteredContent = content.filter(block => {
if (block.type !== 'tool_use') return true
if (!block.id) {
changed = true
return false
}
if (toolUseIdsWithResults.has(block.id)) {
retainedToolUseIds.add(block.id)
return true
}
changed = true
return false
})
if (!changed) {
withoutOrphanToolUses.push(message)
continue
}
if (filteredContent.length > 0) {
withoutOrphanToolUses.push({
...assistantMessage,
message: {
...assistantMessage.message,
content: filteredContent,
},
})
}
continue
}
}
withoutOrphanToolUses.push(message)
}
const filteredMessages: Message[] = []
for (const message of withoutOrphanToolUses) {
if (message?.type !== 'user') {
filteredMessages.push(message)
continue
}
const userMessage = message as UserMessage
const content = userMessage.message.content
if (!Array.isArray(content)) {
filteredMessages.push(message)
continue
}
let changed = false
const filteredContent = content.filter(block => {
if (block.type !== 'tool_result') return true
if (!block.tool_use_id) {
changed = true
return false
}
if (retainedToolUseIds.has(block.tool_use_id)) return true
changed = true
return false
})
if (!changed) {
filteredMessages.push(message)
continue
}
if (filteredContent.length > 0) {
filteredMessages.push({
...userMessage,
message: {
...userMessage.message,
content: filteredContent,
},
})
}
}
return filteredMessages
}

View File

@@ -86,11 +86,8 @@ import {
import type { ContentReplacementState } from 'src/utils/toolResultStorage.js'
import { createAgentId } from 'src/utils/uuid.js'
import { resolveAgentTools } from './agentToolUtils.js'
import { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
export { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
/**
* Initialize agent-specific MCP servers
* Agents can define their own MCP servers in their frontmatter that are additive
@@ -889,6 +886,50 @@ export async function* runAgent({
}
}
/**
* Filters out assistant messages with incomplete tool calls (tool uses without results).
* This prevents API errors when sending messages with orphaned tool calls.
*/
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
// Build a set of tool use IDs that have results
const toolUseIdsWithResults = new Set<string>()
for (const message of messages) {
if (message?.type === 'user') {
const userMessage = message as UserMessage
const content = userMessage.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && block.tool_use_id) {
toolUseIdsWithResults.add(block.tool_use_id)
}
}
}
}
}
// Filter out assistant messages that contain tool calls without results
return messages.filter(message => {
if (message?.type === 'assistant') {
const assistantMessage = message as AssistantMessage
const content = assistantMessage.message.content
if (Array.isArray(content)) {
// Check if this assistant message has any tool uses without results
const hasIncompleteToolCall = content.some(
block =>
block.type === 'tool_use' &&
block.id &&
!toolUseIdsWithResults.has(block.id),
)
// Exclude messages with incomplete tool calls
return !hasIncompleteToolCall
}
}
// Keep all non-assistant messages and assistant messages without tool calls
return true
})
}
async function getAgentSystemPrompt(
agentDefinition: AgentDefinition,
toolUseContext: Pick<ToolUseContext, 'options'>,

View File

@@ -106,84 +106,6 @@ describe("findActualString", () => {
const result = findActualString("hello", "");
expect(result).toBe("");
});
// ── Tab/space normalization (Bug #2 reproduction) ──
test("finds match when search uses spaces but file uses tabs", () => {
// File content uses Tab indentation
const fileContent = "\tif (x) {\n\t\treturn 1;\n\t}";
// User copies from Read output which renders tabs as spaces
const searchWithSpaces = " if (x) {\n return 1;\n }";
const result = findActualString(fileContent, searchWithSpaces);
expect(result).not.toBeNull();
expect(result).toBe(fileContent);
});
test("finds match when search mixes tabs and spaces inconsistently", () => {
const fileContent = "\tconst x = 1; // comment";
const searchMixed = " const x = 1; // comment";
const result = findActualString(fileContent, searchMixed);
expect(result).not.toBeNull();
});
test("finds match for single-line tab-to-space mismatch", () => {
const fileContent = "\t\torder_price = NormalizeDouble(ask, digits);";
const searchSpaces = " order_price = NormalizeDouble(ask, digits);";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
});
// ── CJK / UTF-8 characters (Bug #1 reproduction) ──
test("finds match with CJK characters in content", () => {
const fileContent = "input int x = 620; // 止盈点数(点) — 32个pip=320点";
const result = findActualString(fileContent, fileContent);
expect(result).toBe(fileContent);
});
test("finds match with CJK characters when tab/space differs", () => {
const fileContent = "\t// 向上突破 → Sell Limit (逆方向做空)";
const searchSpaces = " // 向上突破 → Sell Limit (逆方向做空)";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(result).toBe(fileContent);
});
// ── Multiline with tabs + CJK (combined Bug #1 + #2) ──
test("finds multiline match with tabs and CJK characters", () => {
const fileContent = "\tif(effective_dir == BREAKOUT_UP)\n\t\t{\n\t\t\t// 向上突破\n\t\t}";
const searchSpaces = " if(effective_dir == BREAKOUT_UP)\n {\n // 向上突破\n }";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(result).toBe(fileContent);
});
// ── Returned string must be a valid substring of fileContent ──
test("returned string from tab match is a real substring of fileContent", () => {
const fileContent = "prefix\n\t\tindented code\nsuffix";
const searchSpaces = "prefix\n indented code\nsuffix";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(fileContent.includes(result!)).toBe(true);
});
test("returned string from partial tab match is a real substring", () => {
const fileContent = "line1\n\tif (x) {\n\t\tdoStuff();\n\t}\nline5";
const searchSpaces = " if (x) {\n doStuff();\n }";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(fileContent.includes(result!)).toBe(true);
});
test("tab match with mixed indentation levels", () => {
const fileContent = "class Foo {\n\t\tmethod1() {\n\t\t\treturn 42;\n\t\t}\n}";
const searchSpaces = "class Foo {\n method1() {\n return 42;\n }\n}";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(fileContent.includes(result!)).toBe(true);
});
});
// ─── preserveQuoteStyle ─────────────────────────────────────────────────

View File

@@ -63,26 +63,9 @@ export function stripTrailingWhitespace(str: string): string {
return result
}
/**
* Normalizes whitespace for fuzzy matching by converting tabs to spaces
* and collapsing leading whitespace on each line to a canonical form.
* This handles the case where Read tool output renders tabs as spaces,
* so users copy spaces from the output but the file actually has tabs.
*/
function normalizeWhitespace(str: string): string {
return str.replace(/\t/g, ' ')
}
/**
* Finds the actual string in the file content that matches the search string,
* accounting for quote normalization and tab/space differences.
*
* Matching cascade:
* 1. Exact match
* 2. Quote normalization (curly → straight quotes)
* 3. Tab/space normalization (tabs ↔ spaces in leading whitespace)
* 4. Quote + tab/space normalization combined
*
* accounting for quote normalization
* @param fileContent The file content to search in
* @param searchString The string to search for
* @returns The actual string found in the file, or null if not found
@@ -106,92 +89,9 @@ export function findActualString(
return fileContent.substring(searchIndex, searchIndex + searchString.length)
}
// Try with tab/space normalization — handles the case where Read output
// renders tabs as spaces and the user copies the rendered version
const wsNormalizedFile = normalizeWhitespace(fileContent)
const wsNormalizedSearch = normalizeWhitespace(searchString)
const wsSearchIndex = wsNormalizedFile.indexOf(wsNormalizedSearch)
if (wsSearchIndex !== -1) {
// Map the match position back to the original file content.
// We need to find the corresponding range in the original string.
return mapNormalizedMatchBackToFile(fileContent, wsNormalizedFile, wsSearchIndex, wsNormalizedSearch.length)
}
// Try combined: quote normalization + tab/space normalization
const combinedFile = normalizeWhitespace(normalizedFile)
const combinedSearch = normalizeWhitespace(normalizedSearch)
const combinedIndex = combinedFile.indexOf(combinedSearch)
if (combinedIndex !== -1) {
return mapNormalizedMatchBackToFile(fileContent, combinedFile, combinedIndex, combinedSearch.length)
}
return null
}
/**
* Given a match found in a normalized version of fileContent, map the match
* position back to the original fileContent and extract the corresponding
* substring.
*
* Strategy: walk through both strings character by character, building a
* mapping from normalized offset to original offset. When a tab is expanded
* to 4 spaces in the normalized version, the normalized offset advances by 4
* while the original offset advances by 1.
*/
function mapNormalizedMatchBackToFile(
fileContent: string,
normalizedFile: string,
normalizedStart: number,
normalizedLength: number,
): string {
// Build a sparse mapping from normalized position → original position.
// We only need to map the range [normalizedStart, normalizedStart + normalizedLength].
let normPos = 0
let origPos = 0
let origStart = -1
let origEnd = -1
while (origPos < fileContent.length && normPos <= normalizedStart + normalizedLength) {
if (normPos === normalizedStart) {
origStart = origPos
}
if (normPos === normalizedStart + normalizedLength) {
origEnd = origPos
break
}
const origChar = fileContent[origPos]!
if (origChar === '\t') {
// Tab expands to 4 spaces in normalized version
const nextNormPos = normPos + 4
// If normalizedStart falls within this expanded tab, snap to origPos
if (normPos < normalizedStart && nextNormPos > normalizedStart && origStart === -1) {
origStart = origPos
}
if (normPos < normalizedStart + normalizedLength && nextNormPos > normalizedStart + normalizedLength && origEnd === -1) {
origEnd = origPos + 1
}
normPos = nextNormPos
origPos++
} else {
normPos++
origPos++
}
}
// Fallback: if we couldn't map precisely, use character-count heuristic
if (origStart === -1) origStart = 0
if (origEnd === -1) {
// Approximate: use the ratio of original to normalized length
const ratio = fileContent.length / normalizedFile.length
origEnd = Math.round(origStart + normalizedLength * ratio)
}
return fileContent.substring(origStart, origEnd)
}
/**
* When old_string matched via quote normalization (curly quotes in file,
* straight quotes from model), apply the same curly quote style to new_string

View File

@@ -84,48 +84,22 @@ Use this tool to discover messaging targets before sending cross-session message
// UDS socket directory. The implementation scans for live sockets
// and optionally includes Remote Control bridge peers.
const peers: PeerInfo[] = []
const seen = new Set<string>()
const addPeer = (peer: PeerInfo): void => {
if (seen.has(peer.address)) return
seen.add(peer.address)
peers.push(peer)
}
/* eslint-disable @typescript-eslint/no-require-imports */
const udsMessaging =
require('src/utils/udsMessaging.js') as typeof import('src/utils/udsMessaging.js')
const udsClient =
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
const bridgePeers =
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const messagingSocketPath = udsMessaging.getUdsMessagingSocketPath()
// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
// Return discovered peers from the app state.
const appState = context.getAppState()
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
if (messagingSocketPath) {
// Self entry for reference
if (_input.include_self) {
addPeer({
address: udsMessaging.formatUdsAddress(messagingSocketPath),
peers.push({
address: `uds:${messagingSocketPath}`,
name: 'self',
pid: process.pid,
})
}
}
for (const peer of await udsClient.listPeers()) {
if (!peer.messagingSocketPath) continue
addPeer({
address: udsMessaging.formatUdsAddress(peer.messagingSocketPath),
name: peer.name ?? peer.kind,
cwd: peer.cwd,
pid: peer.pid,
})
}
for (const peer of await bridgePeers.listBridgePeers()) {
addPeer(peer)
}
return {
data: { peers },
}

View File

@@ -130,41 +130,6 @@ export type SendMessageToolOutput =
| RequestOutput
| ResponseOutput
const UDS_INLINE_TOKEN_MARKER = '#token='
function stripInlineUdsToken(target: string): string {
const markerIndex = target.indexOf(UDS_INLINE_TOKEN_MARKER)
return markerIndex === -1 ? target : target.slice(0, markerIndex)
}
function hasInlineUdsToken(to: string): boolean {
const addr = parseAddress(to)
// Empty-token markers are still inline-token attempts. Observable input
// redaction preserves "#token=" so cloned inputs remain rejected.
return (
addr.scheme === 'uds' && addr.target.includes(UDS_INLINE_TOKEN_MARKER)
)
}
function recipientForDisplay(to: string): string {
const addr = parseAddress(to)
if (addr.scheme !== 'uds') return to
return `uds:${stripInlineUdsToken(addr.target)}`
}
function redactInlineUdsTokenForRejection(to: string): string {
const addr = parseAddress(to)
if (addr.scheme !== 'uds') return to
const markerIndex = addr.target.indexOf(UDS_INLINE_TOKEN_MARKER)
if (markerIndex === -1) return to
return `uds:${addr.target.slice(0, markerIndex)}${UDS_INLINE_TOKEN_MARKER}`
}
function redactObservableInlineUdsToken(input: { to: string }): void {
if (!hasInlineUdsToken(input.to)) return
input.to = redactInlineUdsTokenForRejection(input.to)
}
function findTeammateColor(
appState: {
teamContext?: { teammates: { [id: string]: { color?: string } } }
@@ -576,17 +541,15 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
backfillObservableInput(input) {
if (typeof input.to !== 'string') return
redactObservableInlineUdsToken(input as { to: string })
if ('type' in input) return
if (typeof input.to !== 'string') return
if (input.to === '*') {
input.type = 'broadcast'
if (typeof input.message === 'string') input.content = input.message
} else if (typeof input.message === 'string') {
input.type = 'message'
input.recipient = recipientForDisplay(input.to)
input.recipient = input.to
input.content = input.message
} else if (typeof input.message === 'object' && input.message !== null) {
const msg = input.message as {
@@ -597,7 +560,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
feedback?: string
}
input.type = msg.type
input.recipient = recipientForDisplay(input.to)
input.recipient = input.to
if (msg.request_id !== undefined) input.request_id = msg.request_id
if (msg.approve !== undefined) input.approve = msg.approve
const content = msg.reason ?? msg.feedback
@@ -606,17 +569,16 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
toAutoClassifierInput(input) {
const recipient = recipientForDisplay(input.to)
if (typeof input.message === 'string') {
return `to ${recipient}: ${input.message}`
return `to ${input.to}: ${input.message}`
}
switch (input.message.type) {
case 'shutdown_request':
return `shutdown_request to ${recipient}`
return `shutdown_request to ${input.to}`
case 'shutdown_response':
return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
case 'plan_approval_response':
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${recipient}`
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}`
}
},
@@ -668,17 +630,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
errorCode: 9,
}
}
if (
addr.scheme === 'uds' &&
hasInlineUdsToken(input.to)
) {
return {
result: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
errorCode: 9,
}
}
if (input.to.includes('@')) {
return {
result: false,
@@ -802,19 +753,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
async call(input, context, canUseTool, assistantMessage) {
if (typeof input.message === 'string') {
const addr = parseAddress(input.to)
if (addr.scheme === 'uds' && hasInlineUdsToken(input.to)) {
return {
data: {
success: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
},
}
}
}
if (feature('UDS_INBOX') && typeof input.message === 'string') {
const addr = parseAddress(input.to)
if (addr.scheme === 'bridge') {
@@ -834,10 +772,10 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
const { postInterClaudeMessage } =
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const result = (await postInterClaudeMessage(
const result = await postInterClaudeMessage(
addr.target,
input.message,
)) as { ok: boolean; error?: string }
) as { ok: boolean; error?: string }
const preview = input.summary || truncate(input.message, 50)
return {
data: {
@@ -849,7 +787,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
}
}
if (addr.scheme === 'uds') {
const recipient = recipientForDisplay(input.to)
/* eslint-disable @typescript-eslint/no-require-imports */
const { sendToUdsSocket } =
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
@@ -860,14 +797,14 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
return {
data: {
success: true,
message: `${preview}” → ${recipient}`,
message: `${preview}” → ${input.to}`,
},
}
} catch (e) {
return {
data: {
success: false,
message: `Failed to send to ${recipient}: ${errorMessage(e)}`,
message: `Failed to send to ${input.to}: ${errorMessage(e)}`,
},
}
}

View File

@@ -1,181 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { SendMessageTool } from '../SendMessageTool.js'
describe('SendMessageTool UDS recipient handling', () => {
test('redacts inline UDS tokens before classifier and observable paths', async () => {
const tokenAddress = 'uds:/tmp/peer.sock#token=secret-token'
const observableInput = {
to: tokenAddress,
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
expect(
SendMessageTool.toAutoClassifierInput({
to: tokenAddress,
message: 'hello',
}),
).toBe('to uds:/tmp/peer.sock: hello')
})
test('keeps redacted UDS token rejection through observable backfill', async () => {
const observableInput = {
to: 'uds:/tmp/peer.sock#token=secret-token',
message: {
type: 'plan_approval_response',
request_id: 'req-1',
approve: false,
reason: 'needs tests',
},
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(observableInput.type).toBe('plan_approval_response')
expect(observableInput.request_id).toBe('req-1')
expect(observableInput.approve).toBe(false)
expect(observableInput.content).toBe('needs tests')
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
const result = await SendMessageTool.validateInput!(
observableInput as never,
{} as never,
)
expect(result.result).toBe(false)
if (result.result !== false) {
throw new Error('expected validation to reject redacted inline UDS token')
}
expect(result.message).toContain('inline auth tokens')
})
test('keeps inline-token rejection when observable input is cloned', async () => {
const observableInput = {
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
const clonedInput = {
to: observableInput.to,
message: observableInput.message,
summary: 'hello peer',
}
const validation = await SendMessageTool.validateInput!(
clonedInput as never,
{} as never,
)
const result = await SendMessageTool.call(
clonedInput as never,
{} as never,
undefined as never,
undefined as never,
)
expect(validation.result).toBe(false)
expect(result.data.success).toBe(false)
expect(JSON.stringify(clonedInput)).not.toContain('secret-token')
expect(JSON.stringify(result)).not.toContain('secret-token')
})
test('redacts UDS tokens in structured classifier text', async () => {
const to = 'uds:/tmp/peer.sock#token=secret-token'
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: { type: 'shutdown_request' },
}),
).toBe('shutdown_request to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'plan_approval_response',
request_id: 'req-1',
approve: true,
},
}),
).toBe('plan_approval approve to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'plan_approval_response',
request_id: 'req-2',
approve: false,
},
}),
).toBe('plan_approval reject to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'shutdown_response',
request_id: 'shutdown-1',
approve: false,
},
}),
).toBe('shutdown_response reject shutdown-1')
})
test('redacts from the first inline UDS token marker', async () => {
const tokenAddress = 'uds:/tmp/peer.sock#token=first#token=second'
const observableInput = {
to: tokenAddress,
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(JSON.stringify(observableInput)).not.toContain('first')
expect(JSON.stringify(observableInput)).not.toContain('second')
expect(
SendMessageTool.toAutoClassifierInput({
to: tokenAddress,
message: 'hello',
}),
).toBe('to uds:/tmp/peer.sock: hello')
})
test('rejects inline UDS tokens during validation', async () => {
const result = await SendMessageTool.validateInput!(
{
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
},
{} as never,
)
expect(result.result).toBe(false)
if (result.result !== false) {
throw new Error('expected validation to reject inline UDS token')
}
expect(result.message).toContain('inline auth tokens')
expect(JSON.stringify(result)).not.toContain('secret-token')
})
test('rejects inline UDS tokens during execution without leaking them', async () => {
const result = await SendMessageTool.call(
{
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
},
{} as never,
undefined as never,
undefined as never,
)
expect(result.data.success).toBe(false)
expect(JSON.stringify(result)).not.toContain('secret-token')
})
})

View File

@@ -6,38 +6,6 @@ import { getBridgeAccessToken } from './bridgeConfig.js'
import { getReplBridgeHandle } from './replBridgeHandle.js'
import { toCompatSessionId } from './sessionIdCompat.js'
export type BridgePeerSession = {
address: string
name?: string
cwd?: string
pid?: number
}
/**
* List locally registered sessions that have published a Remote Control
* session ID. The PID registry is the local source of truth for bridge peers
* already known to this machine; SendMessage can use these bridge:<id>
* addresses when the current process has an active bridge handle.
*/
export async function listBridgePeers(): Promise<BridgePeerSession[]> {
const { listAllLiveSessions } = await import('../utils/udsClient.js')
const sessions = await listAllLiveSessions()
const peers: BridgePeerSession[] = []
for (const session of sessions) {
if (session.pid === process.pid || !session.bridgeSessionId) continue
const compatId = toCompatSessionId(session.bridgeSessionId)
peers.push({
address: `bridge:${compatId}`,
name: session.name ?? session.kind,
cwd: session.cwd,
pid: session.pid,
})
}
return peers
}
/**
* Send a plain-text message to another Claude session via the bridge API.
*

View File

@@ -2763,37 +2763,13 @@ function runHeadlessStreaming(
// when a message arrives via the UDS socket in headless mode.
if (feature('UDS_INBOX')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { drainInbox, setOnEnqueue } =
require('../utils/udsMessaging.js') as typeof import('../utils/udsMessaging.js')
const { setOnEnqueue } = require('../utils/udsMessaging.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const enqueueUdsInboxMessages = (): boolean => {
const entries = drainInbox()
for (const entry of entries) {
const value =
typeof entry.message.data === 'string'
? entry.message.data
: jsonStringify(entry.message.data)
enqueue({
mode: 'prompt',
value,
uuid: randomUUID(),
})
}
return entries.length > 0
}
setOnEnqueue(() => {
if (!inputClosed) {
if (enqueueUdsInboxMessages()) {
void run()
}
void run()
}
})
if (enqueueUdsInboxMessages()) {
void run()
}
}
// Cron scheduler: runs scheduled_tasks.json tasks in SDK/-p mode.

View File

@@ -1,9 +1,6 @@
import type { LocalCommandCall } from '../../types/command.js'
import { listPeers, isPeerAlive } from '../../utils/udsClient.js'
import {
formatUdsAddress,
getUdsMessagingSocketPath,
} from '../../utils/udsMessaging.js'
import { getUdsMessagingSocketPath } from '../../utils/udsMessaging.js'
export const call: LocalCommandCall = async (_args, _context) => {
const mySocket = getUdsMessagingSocketPath()
@@ -32,11 +29,11 @@ export const call: LocalCommandCall = async (_args, _context) => {
? ` started: ${formatAge(peer.startedAt)}`
: ''
lines.push(` [${status}] PID ${peer.pid} (${label})${cwd}${age}`)
lines.push(
` [${status}] PID ${peer.pid} (${label})${cwd}${age}`,
)
if (peer.messagingSocketPath) {
lines.push(
` socket: ${formatUdsAddress(peer.messagingSocketPath)}`,
)
lines.push(` socket: ${peer.messagingSocketPath}`)
}
if (peer.sessionId) {
lines.push(` session: ${peer.sessionId}`)
@@ -46,7 +43,7 @@ export const call: LocalCommandCall = async (_args, _context) => {
lines.push('')
lines.push(
'To message a peer: use SendMessage with the shown uds:<socket-path> address',
'To message a peer: use SendMessage with to="uds:<socket-path>"',
)
return { type: 'text', value: lines.join('\n') }

View File

@@ -5,8 +5,7 @@
* After the fix, it reads from / writes to settings.json via
* getInitialSettings() and updateSettingsForSource().
*/
import { afterAll, describe, expect, test, beforeEach, mock } from 'bun:test'
import * as settingsModule from '../../../utils/settings/settings.js'
import { describe, expect, test, beforeEach, mock } from 'bun:test'
// ── Mocks must be declared before the module under test is imported ──────────
@@ -14,48 +13,24 @@ let mockSettings: Record<string, unknown> = {}
let lastUpdate: { source: string; patch: Record<string, unknown> } | null = null
mock.module('src/utils/settings/settings.js', () => ({
loadManagedFileSettings: () => ({ settings: null, errors: [] }),
getManagedFileSettingsPresence: () => ({
hasBase: false,
hasDropIns: false,
}),
parseSettingsFile: () => ({ settings: null, errors: [] }),
getSettingsRootPathForSource: () => '',
getSettingsFilePathForSource: () => undefined,
getRelativeSettingsFilePathForSource: () => '',
getInitialSettings: () => mockSettings,
getSettingsForSource: () => mockSettings,
getPolicySettingsOrigin: () => null,
getSettingsWithErrors: () => ({ settings: mockSettings, errors: [] }),
getSettingsWithSources: () => ({ effective: mockSettings, sources: [] }),
getSettings_DEPRECATED: () => mockSettings,
settingsMergeCustomizer: () => undefined,
getManagedSettingsKeysForLogging: () => [],
// Keep unrelated exports aligned with the real settings module so this
// full-surface mock cannot change later test files if Bun keeps it alive.
hasAutoModeOptIn: () => true,
hasSkipDangerousModePermissionPrompt: () => false,
getAutoModeConfig: () => undefined,
getUseAutoModeDuringPlan: () => true,
rawSettingsContainsKey: (key: string) => key in mockSettings,
updateSettingsForSource: (source: string, patch: Record<string, unknown>) => {
lastUpdate = { source, patch }
mockSettings = { ...mockSettings, ...patch }
},
}))
afterAll(() => {
mock.restore()
mock.module('src/utils/settings/settings.js', () => settingsModule)
})
// Import AFTER mocks are registered
const { isPoorModeActive, setPoorMode } = await import('../poorMode.js')
// Import AFTER mocks are registered. The query suffix gives this file its own
// module instance so cross-file poorMode.js mocks cannot replace the subject
// under test during Bun's shared coverage run.
const poorModeModulePath = '../poorMode.js?poorModeTest'
const { isPoorModeActive, setPoorMode } = (await import(
poorModeModulePath
)) as typeof import('../poorMode.js')
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Reset module-level singleton between tests by re-importing a fresh copy. */
async function freshModule() {
// Bun caches modules; we manipulate the exported functions directly since
// the singleton `poorModeActive` is reset to null only on first import.
// Instead we test the observable behaviour through set/get pairs.
}
// ── Tests ────────────────────────────────────────────────────────────────────

View File

@@ -63,6 +63,7 @@ const call: LocalCommandCall = async (args, context) => {
const validProviders = [
'anthropic',
'openai',
'codex',
'gemini',
'grok',
'bedrock',
@@ -120,10 +121,23 @@ const call: LocalCommandCall = async (args, context) => {
}
}
// Check env vars when switching to codex (including settings.env)
if (arg === 'codex') {
const mergedEnv = getMergedEnv()
const hasKey = !!(mergedEnv.CODEX_API_KEY || mergedEnv.CODEX_ACCESS_TOKEN)
if (!hasKey) {
updateSettingsForSource('userSettings', { modelType: 'codex' })
return {
type: 'text',
value: `Switched to Codex provider.\nWarning: No CODEX_API_KEY or CODEX_ACCESS_TOKEN found.\nUse /login (ChatGPT Subscription) or set manually.`,
}
}
}
// Handle different provider types
// - 'anthropic', 'openai', 'gemini' are stored in settings.json (persistent)
// - 'bedrock', 'vertex', 'foundry' are env-only (do NOT touch settings.json)
if (arg === 'anthropic' || arg === 'openai' || arg === 'gemini' || arg === 'grok') {
if (arg === 'anthropic' || arg === 'openai' || arg === 'codex' || arg === 'gemini' || arg === 'grok') {
// Clear any cloud provider env vars to avoid conflicts
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
@@ -131,7 +145,7 @@ const call: LocalCommandCall = async (args, context) => {
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
// Update settings.json
delete process.env.CLAUDE_CODE_USE_CODEX
updateSettingsForSource('userSettings', { modelType: arg })
// Ensure settings.env gets applied to process.env
applyConfigEnvironmentVariables()
@@ -157,9 +171,9 @@ const provider = {
type: 'local',
name: 'provider',
description:
'Switch API provider (anthropic/openai/gemini/grok/bedrock/vertex/foundry)',
'Switch API provider (anthropic/openai/codex/gemini/grok/bedrock/vertex/foundry)',
aliases: ['api'],
argumentHint: '[anthropic|openai|gemini|grok|bedrock|vertex|foundry|unset]',
argumentHint: '[anthropic|openai|codex|gemini|grok|bedrock|vertex|foundry|unset]',
supportsNonInteractive: true,
load: () => Promise.resolve({ call }),
} satisfies Command

View File

@@ -10,6 +10,7 @@ import { useKeybinding } from '../keybindings/useKeybinding.js'
import { getSSLErrorHint } from '@ant/model-provider'
import { sendNotification } from '../services/notifier.js'
import { OAuthService } from '../services/oauth/index.js'
import { performOpenAICodexLogin, parseManualCodeInput } from '../services/oauth/openai-codex.js'
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'
import { logError } from '../utils/log.js'
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'
@@ -55,6 +56,20 @@ type OAuthStatus =
opusModel: string
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
} // Gemini Generate Content API platform
| { state: 'codex_oauth_waiting'; url: string } // ChatGPT OAuth browser login in progress
| { state: 'codex_oauth_start' } // Trigger ChatGPT OAuth flow
| {
state: 'codex_models'
haikuModel: string
sonnetModel: string
opusModel: string
activeField: 'haiku_model' | 'sonnet_model' | 'opus_model'
codexResult: {
apiKey: string | null
accessToken: string
refreshToken: string
}
} // Codex model name configuration after OAuth success
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
| { state: 'creating_api_key' } // Got access token, creating API key
@@ -108,6 +123,13 @@ export function ConsoleOAuthFlow({
const [showPastePrompt, setShowPastePrompt] = useState(false)
const [urlCopied, setUrlCopied] = useState(false)
// Codex ChatGPT OAuth states
const [showCodexPastePrompt, setShowCodexPastePrompt] = useState(false)
const [codexUrlCopied, setCodexUrlCopied] = useState(false)
const [codexPastedCode, setCodexPastedCode] = useState('')
const [codexPastedCursor, setCodexPastedCursor] = useState(0)
const codexManualCodeResolveRef = useRef<((code: string) => void) | null>(null)
const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
// Log forced login method on mount
@@ -186,6 +208,39 @@ export function ConsoleOAuthFlow({
}
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied])
// Codex OAuth: copy URL on 'c'
useEffect(() => {
if (
codexPastedCode === 'c' &&
oauthStatus.state === 'codex_oauth_waiting' &&
showCodexPastePrompt &&
!codexUrlCopied
) {
const url = (oauthStatus as { state: 'codex_oauth_waiting'; url: string }).url
void setClipboard(url).then(raw => {
if (raw) process.stdout.write(raw)
setCodexUrlCopied(true)
setTimeout(setCodexUrlCopied, 2000, false)
})
setCodexPastedCode('')
}
}, [codexPastedCode, oauthStatus, showCodexPastePrompt, codexUrlCopied])
// Codex OAuth: submit pasted code
const handleCodexPasteSubmit = useCallback((value: string) => {
const code = parseManualCodeInput(value)
if (!code) {
setOAuthStatus({
state: 'error',
message: 'Invalid code. Paste the full redirect URL or just the authorization code.',
toRetry: oauthStatus as any,
})
return
}
codexManualCodeResolveRef.current?.(code)
codexManualCodeResolveRef.current = null
}, [oauthStatus])
async function handleSubmitCode(value: string, url: string) {
try {
// Expecting format "authorizationCode#state" from the authorization callback URL
@@ -301,6 +356,52 @@ export function ConsoleOAuthFlow({
}
}, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID])
const startCodexOAuth = useCallback(async () => {
setShowCodexPastePrompt(false)
setCodexUrlCopied(false)
setCodexPastedCode('')
setCodexPastedCursor(0)
let manualCodeResolve: ((code: string) => void) | null = null
const manualCodePromise = new Promise<string>(resolve => {
manualCodeResolve = resolve
})
codexManualCodeResolveRef.current = manualCodeResolve
try {
const result = await performOpenAICodexLogin({
onUrl: url => {
setOAuthStatus({ state: 'codex_oauth_waiting', url })
setTimeout(setShowCodexPastePrompt, 3000, true)
},
manualCode: manualCodePromise,
})
// Transition to model configuration panel with defaults
setOAuthStatus({
state: 'codex_models',
haikuModel: process.env.CODEX_DEFAULT_HAIKU_MODEL || 'gpt-5.4-mini',
sonnetModel: process.env.CODEX_DEFAULT_SONNET_MODEL || 'gpt-5.4-mini',
opusModel: process.env.CODEX_DEFAULT_OPUS_MODEL || 'gpt-5.5',
activeField: 'haiku_model',
codexResult: {
apiKey: result.apiKey,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
})
} catch (err) {
logError(err as Error)
setOAuthStatus({
state: 'error',
message: (err as Error).message,
toRetry: { state: 'idle' },
})
} finally {
codexManualCodeResolveRef.current = null
}
}, [onDone])
const pendingOAuthStartRef = useRef(false)
useEffect(() => {
@@ -316,6 +417,19 @@ export function ConsoleOAuthFlow({
}
}, [oauthStatus.state, startOAuth])
const pendingCodexOAuthRef = useRef(false)
useEffect(() => {
if (
oauthStatus.state === 'codex_oauth_start' &&
!pendingCodexOAuthRef.current
) {
pendingCodexOAuthRef.current = true
void startCodexOAuth().finally(() => {
pendingCodexOAuthRef.current = false
})
}
}, [oauthStatus.state, startCodexOAuth])
// Auto-exit for setup-token mode
useEffect(() => {
if (mode === 'setup-token' && oauthStatus.state === 'success') {
@@ -334,6 +448,20 @@ export function ConsoleOAuthFlow({
}
}, [mode, oauthStatus, loginWithClaudeAi, onDone])
// Cancel codex OAuth with Escape
useKeybinding(
'confirm:no',
() => {
setShowCodexPastePrompt(false)
setCodexPastedCode('')
setOAuthStatus({ state: 'idle' })
},
{
context: 'Confirmation',
isActive: oauthStatus.state === 'codex_oauth_waiting',
},
)
// Cleanup OAuth service when component unmounts
useEffect(() => {
return () => {
@@ -399,6 +527,13 @@ export function ConsoleOAuthFlow({
setOAuthStatus={setOAuthStatus}
setLoginWithClaudeAi={setLoginWithClaudeAi}
onDone={onDone}
showCodexPastePrompt={showCodexPastePrompt}
codexUrlCopied={codexUrlCopied}
codexPastedCode={codexPastedCode}
setCodexPastedCode={setCodexPastedCode}
codexPastedCursor={codexPastedCursor}
setCodexPastedCursor={setCodexPastedCursor}
handleCodexPasteSubmit={handleCodexPasteSubmit}
/>
</Box>
</Box>
@@ -420,6 +555,14 @@ type OAuthStatusMessageProps = {
handleSubmitCode: (value: string, url: string) => void
setOAuthStatus: (status: OAuthStatus) => void
setLoginWithClaudeAi: (value: boolean) => void
// Codex ChatGPT OAuth props
showCodexPastePrompt: boolean
codexUrlCopied: boolean
codexPastedCode: string
setCodexPastedCode: (value: string) => void
codexPastedCursor: number
setCodexPastedCursor: (offset: number) => void
handleCodexPasteSubmit: (value: string) => void
}
function OAuthStatusMessage({
@@ -437,6 +580,13 @@ function OAuthStatusMessage({
setOAuthStatus,
setLoginWithClaudeAi,
onDone,
showCodexPastePrompt,
codexUrlCopied,
codexPastedCode,
setCodexPastedCode,
codexPastedCursor,
setCodexPastedCursor,
handleCodexPasteSubmit,
}: OAuthStatusMessageProps): React.ReactNode {
switch (oauthStatus.state) {
case 'idle':
@@ -475,6 +625,16 @@ function OAuthStatusMessage({
),
value: 'openai_chat_api',
},
{
label: (
<Text>
OpenAI Codex (ChatGPT Subscription) -{' '}
<Text dimColor>Login with ChatGPT Plus/Pro</Text>
{'\n'}
</Text>
),
value: 'codex_chatgpt',
},
{
label: (
<Text>
@@ -552,6 +712,39 @@ function OAuthStatusMessage({
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
})
} else if (value === 'codex_chatgpt') {
logEvent('tengu_codex_chatgpt_selected', {})
// Skip OAuth if already authenticated — go straight to model config
const settings = getSettings_DEPRECATED()
const hasToken = !!(
process.env.CODEX_ACCESS_TOKEN ||
settings?.env?.CODEX_ACCESS_TOKEN
)
if (hasToken) {
setOAuthStatus({
state: 'codex_models',
haikuModel:
process.env.CODEX_DEFAULT_HAIKU_MODEL ||
settings?.env?.CODEX_DEFAULT_HAIKU_MODEL ||
'gpt-5.4-mini',
sonnetModel:
process.env.CODEX_DEFAULT_SONNET_MODEL ||
settings?.env?.CODEX_DEFAULT_SONNET_MODEL ||
'gpt-5.4-mini',
opusModel:
process.env.CODEX_DEFAULT_OPUS_MODEL ||
settings?.env?.CODEX_DEFAULT_OPUS_MODEL ||
'gpt-5.5',
activeField: 'haiku_model',
codexResult: {
apiKey: process.env.CODEX_API_KEY || null,
accessToken: process.env.CODEX_ACCESS_TOKEN || '',
refreshToken: process.env.CODEX_REFRESH_TOKEN || '',
},
})
} else {
setOAuthStatus({ state: 'codex_oauth_start' })
}
} else if (value === 'gemini_api') {
logEvent('tengu_gemini_api_selected', {})
setOAuthStatus({
@@ -1275,6 +1468,282 @@ function OAuthStatusMessage({
)
}
case 'codex_oauth_waiting': {
const { url } = oauthStatus as { state: 'codex_oauth_waiting'; url: string }
const codexPasteColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
return (
<Box flexDirection="column" gap={1}>
{!showCodexPastePrompt && (
<Box>
<Spinner />
<Text>Opening browser for ChatGPT login...</Text>
</Box>
)}
{showCodexPastePrompt && (
<Box flexDirection="column" gap={1}>
<Box paddingX={1}>
<Text dimColor>
Browser didn&apos;t open? Use the url below to sign in{' '}
</Text>
{codexUrlCopied ? (
<Text color="success">(Copied!)</Text>
) : (
<Text dimColor>
<KeyboardShortcutHint shortcut="c" action="copy" parens />
</Text>
)}
</Box>
<Link url={url}>
<Text dimColor>{url}</Text>
</Link>
</Box>
)}
{showCodexPastePrompt && (
<Box>
<Text>{PASTE_HERE_MSG}</Text>
<TextInput
value={codexPastedCode}
onChange={setCodexPastedCode}
onSubmit={handleCodexPasteSubmit}
cursorOffset={codexPastedCursor}
onChangeCursorOffset={setCodexPastedCursor}
columns={codexPasteColumns}
mask="*"
/>
</Box>
)}
<Text dimColor>
Press <Text bold>Esc</Text> to cancel
</Text>
</Box>
)
}
case 'codex_models': {
type CodexField = 'haiku_model' | 'sonnet_model' | 'opus_model'
const CODEX_FIELDS: CodexField[] = ['haiku_model', 'sonnet_model', 'opus_model']
const cm = oauthStatus as {
state: 'codex_models'
activeField: CodexField
haikuModel: string
sonnetModel: string
opusModel: string
codexResult: { apiKey: string | null; accessToken: string; refreshToken: string }
}
const { activeField, haikuModel, sonnetModel, opusModel, codexResult } = cm
const codexDisplayValues: Record<CodexField, string> = {
haiku_model: haikuModel,
sonnet_model: sonnetModel,
opus_model: opusModel,
}
const [codexModelInput, setCodexModelInput] = useState(
() => codexDisplayValues[activeField],
)
const [codexModelCursor, setCodexModelCursor] = useState(
() => codexDisplayValues[activeField].length,
)
const buildCodexModelState = useCallback(
(field: CodexField, value: string, newActive?: CodexField) => {
const s = {
state: 'codex_models' as const,
activeField: newActive ?? activeField,
haikuModel,
sonnetModel,
opusModel,
codexResult,
}
switch (field) {
case 'haiku_model':
return { ...s, haikuModel: value }
case 'sonnet_model':
return { ...s, sonnetModel: value }
case 'opus_model':
return { ...s, opusModel: value }
}
},
[activeField, haikuModel, sonnetModel, opusModel, codexResult],
)
const doCodexModelSave = useCallback(() => {
const finalVals = { ...codexDisplayValues, [activeField]: codexModelInput }
const env: Record<string, string | undefined> = {
CODEX_API_KEY: codexResult.apiKey ?? undefined,
CODEX_ACCESS_TOKEN: codexResult.accessToken,
CODEX_REFRESH_TOKEN: codexResult.refreshToken,
CODEX_LOGIN_METHOD: 'chatgpt_subscription',
CODEX_DEFAULT_HAIKU_MODEL: finalVals.haiku_model,
CODEX_DEFAULT_SONNET_MODEL: finalVals.sonnet_model,
CODEX_DEFAULT_OPUS_MODEL: finalVals.opus_model,
}
const { error } = updateSettingsForSource('userSettings', {
modelType: 'codex' as any,
env,
} as any)
if (error) {
setOAuthStatus({
state: 'error',
message: 'Failed to save settings. Please try again.',
toRetry: {
state: 'codex_models',
haikuModel: finalVals.haiku_model,
sonnetModel: finalVals.sonnet_model,
opusModel: finalVals.opus_model,
activeField: 'haiku_model',
codexResult,
},
})
} else {
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) {
process.env[k] = v
}
}
setOAuthStatus({ state: 'success' })
void onDone()
}
}, [activeField, codexModelInput, codexDisplayValues, codexResult, setOAuthStatus, onDone])
const handleCodexModelEnter = useCallback(() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx === CODEX_FIELDS.length - 1) {
setOAuthStatus(buildCodexModelState(activeField, codexModelInput))
doCodexModelSave()
} else {
const next = CODEX_FIELDS[idx + 1]!
setOAuthStatus(buildCodexModelState(activeField, codexModelInput, next))
setCodexModelInput(codexDisplayValues[next] ?? '')
setCodexModelCursor((codexDisplayValues[next] ?? '').length)
}
}, [
activeField,
codexModelInput,
buildCodexModelState,
doCodexModelSave,
codexDisplayValues,
setOAuthStatus,
])
useKeybinding(
'tabs:next',
() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx < CODEX_FIELDS.length - 1) {
setOAuthStatus(
buildCodexModelState(activeField, codexModelInput, CODEX_FIELDS[idx + 1]),
)
setCodexModelInput(codexDisplayValues[CODEX_FIELDS[idx + 1]!] ?? '')
setCodexModelCursor((codexDisplayValues[CODEX_FIELDS[idx + 1]!] ?? '').length)
}
},
{ context: 'FormField' },
)
useKeybinding(
'tabs:previous',
() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx > 0) {
setOAuthStatus(
buildCodexModelState(activeField, codexModelInput, CODEX_FIELDS[idx - 1]),
)
setCodexModelInput(codexDisplayValues[CODEX_FIELDS[idx - 1]!] ?? '')
setCodexModelCursor((codexDisplayValues[CODEX_FIELDS[idx - 1]!] ?? '').length)
}
},
{ context: 'FormField' },
)
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'idle' })
},
{ context: 'Confirmation' },
)
// Ctrl+D: clear codex login state and re-login
useKeybinding(
'oauth:codex-relogin',
() => {
// Clear codex credentials from process.env
delete process.env.CODEX_ACCESS_TOKEN
delete process.env.CODEX_REFRESH_TOKEN
delete process.env.CODEX_API_KEY
delete process.env.CODEX_LOGIN_METHOD
delete process.env.CODEX_DEFAULT_HAIKU_MODEL
delete process.env.CODEX_DEFAULT_SONNET_MODEL
delete process.env.CODEX_DEFAULT_OPUS_MODEL
// Clear from settings.json
updateSettingsForSource('userSettings', {
modelType: undefined,
env: {
CODEX_ACCESS_TOKEN: undefined,
CODEX_REFRESH_TOKEN: undefined,
CODEX_API_KEY: undefined,
CODEX_LOGIN_METHOD: undefined,
CODEX_DEFAULT_HAIKU_MODEL: undefined,
CODEX_DEFAULT_SONNET_MODEL: undefined,
CODEX_DEFAULT_OPUS_MODEL: undefined,
},
} as any)
// Restart OAuth flow
setOAuthStatus({ state: 'codex_oauth_start' })
},
{ context: 'FormField' },
)
const codexModelColumns = useTerminalSize().columns - 20
const renderCodexModelRow = (
field: CodexField,
label: string,
) => {
const active = activeField === field
const val = codexDisplayValues[field]
return (
<Box>
<Text
backgroundColor={active ? 'suggestion' : undefined}
color={active ? 'inverseText' : undefined}
>
{` ${label} `}
</Text>
<Text> </Text>
{active ? (
<TextInput
value={codexModelInput}
onChange={setCodexModelInput}
onSubmit={handleCodexModelEnter}
cursorOffset={codexModelCursor}
onChangeCursorOffset={setCodexModelCursor}
columns={codexModelColumns}
focus={true}
/>
) : val ? (
<Text color="success">{val}</Text>
) : null}
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text bold>Codex Model Configuration</Text>
<Text dimColor>
ChatGPT login successful. Configure model names (press Enter on last field to save).
</Text>
<Box flexDirection="column" gap={1}>
{renderCodexModelRow('haiku_model', 'Haiku ')}
{renderCodexModelRow('sonnet_model', 'Sonnet ')}
{renderCodexModelRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>
/Tab to switch · Enter on last field to save · Ctrl+R to re-login · Esc to go back
</Text>
</Box>
)
}
case 'platform_setup':
return (
<Box flexDirection="column" gap={1} marginTop={1}>

View File

@@ -77,8 +77,6 @@ export type Props = {
lastThinkingBlockId?: string | null
/** UUID of the latest user bash output message (for auto-expanding) */
latestBashOutputUUID?: string | null
/** Whether to collapse diff display for this message */
shouldCollapseDiffs?: boolean
}
function MessageImpl({
@@ -101,7 +99,6 @@ function MessageImpl({
isUserContinuation = false,
lastThinkingBlockId,
latestBashOutputUUID,
shouldCollapseDiffs,
}: Props): React.ReactNode {
switch (message.type) {
case 'attachment':
@@ -184,7 +181,6 @@ function MessageImpl({
isUserContinuation={isUserContinuation}
lookups={lookups}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
))}
</Box>
@@ -297,7 +293,6 @@ function UserMessage({
isUserContinuation,
lookups,
isTranscriptMode,
shouldCollapseDiffs,
}: {
message: NormalizedUserMessage
addMargin: boolean
@@ -314,7 +309,6 @@ function UserMessage({
isUserContinuation: boolean
lookups: ReturnType<typeof buildMessageLookups>
isTranscriptMode: boolean
shouldCollapseDiffs?: boolean
}): React.ReactNode {
const { columns } = useTerminalSize()
switch (param.type) {
@@ -350,7 +344,6 @@ function UserMessage({
verbose={verbose}
width={columns - 5}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
default:

View File

@@ -55,7 +55,6 @@ export type Props = {
columns: number
isLoading: boolean
lookups: ReturnType<typeof buildMessageLookups>
shouldCollapseDiffs?: boolean
}
/**
@@ -142,7 +141,6 @@ function MessageRowImpl({
columns,
isLoading,
lookups,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const isTranscriptMode = screen === 'transcript'
const isGrouped = msg.type === 'grouped_tool_use'
@@ -223,7 +221,6 @@ function MessageRowImpl({
isUserContinuation={isUserContinuation}
lastThinkingBlockId={lastThinkingBlockId}
latestBashOutputUUID={latestBashOutputUUID}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
// OffscreenFreeze: the outer React.memo already bails for static messages,

View File

@@ -814,12 +814,6 @@ const MessagesImpl = ({
streamingToolUseIDs,
))
// Collapse diffs for messages beyond the latest N messages.
// verbose (ctrl+o) overrides and always shows full diffs.
const DIFF_COLLAPSE_DISTANCE = 0
const shouldCollapseDiffs =
renderableMessages.length - 1 - index > DIFF_COLLAPSE_DISTANCE
const k = messageKey(msg)
const row = (
<MessageRow
@@ -844,7 +838,6 @@ const MessagesImpl = ({
columns={columns}
isLoading={isLoading}
lookups={lookups}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)

View File

@@ -27,7 +27,6 @@ type Props = {
verbose: boolean
width: number | string
isTranscriptMode?: boolean
shouldCollapseDiffs?: boolean
}
export function UserToolResultMessage({
@@ -40,7 +39,6 @@ export function UserToolResultMessage({
verbose,
width,
isTranscriptMode,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups)
if (!toolUse) {
@@ -98,7 +96,6 @@ export function UserToolResultMessage({
verbose={verbose}
width={width}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
}

View File

@@ -33,7 +33,6 @@ type Props = {
verbose: boolean
width: number | string
isTranscriptMode?: boolean
shouldCollapseDiffs?: boolean
}
export function UserToolSuccessMessage({
@@ -47,7 +46,6 @@ export function UserToolSuccessMessage({
verbose,
width,
isTranscriptMode,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const [theme] = useTheme()
// Hook stays inside feature() ternary so external builds don't pay a
@@ -85,16 +83,12 @@ export function UserToolSuccessMessage({
}
const toolResult = parsedOutput?.data ?? message.toolUseResult
// Collapse diff display for old messages (verbose/ctrl+o overrides)
const effectiveStyle =
shouldCollapseDiffs && !verbose ? 'condensed' : style
const renderedMessage =
tool.renderToolResultMessage?.(
toolResult as never,
filterToolProgressMessages(progressMessagesForMessage),
{
style: effectiveStyle,
style,
theme,
tools,
verbose,

View File

@@ -156,6 +156,8 @@ export const DEFAULT_BINDINGS: KeybindingBlock[] = [
'shift+tab': 'tabs:previous',
up: 'tabs:previous',
down: 'tabs:next',
// Re-login: clear codex credentials and restart OAuth
'ctrl+r': 'oauth:codex-relogin',
},
},
{

View File

@@ -109,6 +109,8 @@ export const KEYBINDING_ACTIONS = [
// Tabs navigation actions
'tabs:next',
'tabs:previous',
// OAuth re-login action (codex model config panel)
'oauth:codex-relogin',
// Transcript viewer actions
'transcript:toggleShowAll',
'transcript:exit',

View File

@@ -6907,9 +6907,6 @@ async function logTenguInit({
allowDangerouslySkipPermissionsPassed,
thinkingType:
thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingConfig.type === "enabled" && {
thinkingBudgetTokens: thinkingConfig.budgetTokens,
}),
...(systemPromptFlag && {
systemPromptFlag:
systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,

View File

@@ -1,228 +0,0 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { asAgentId } from '../../../types/ids.js'
import type { Message } from '../../../types/message.js'
import type {
CacheSafeParams,
ForkedAgentResult,
} from '../../../utils/forkedAgent.js'
import {
type AgentSummaryDependencies,
startAgentSummarization,
} from '../agentSummary.js'
const transcriptMessages = [
{ type: 'user', message: { content: 'start' }, uuid: 'u1' },
{
type: 'assistant',
message: { content: [{ type: 'text', text: 'working' }] },
uuid: 'a1',
},
{ type: 'user', message: { content: 'continue' }, uuid: 'u2' },
] as unknown as Message[]
type ForkCall = {
cacheSafeParams: CacheSafeParams
}
describe('startAgentSummarization', () => {
let scheduled: (() => void | Promise<void>) | undefined
let handle: { stop: () => void } | undefined
let forkCalls: ForkCall[]
let updateCalls: Array<{ taskId: string; summary: string }>
let transcriptMessagesForTest: Message[]
let debugLogs: string[]
let loggedErrors: Error[]
let clearedHandles: unknown[]
let scheduledCount: number
let lastTimerHandle: unknown
function startTestSummarization(
dependencies: AgentSummaryDependencies = {},
): { stop: () => void } {
return startAgentSummarization(
'task-1',
asAgentId('a0000000000000000'),
{
forkContextMessages: [
{ type: 'user', message: { content: 'stale' }, uuid: 'old' },
],
model: 'claude-test',
} as unknown as CacheSafeParams,
() => undefined,
{
clearTimeout: ((timeoutId: unknown) => {
clearedHandles.push(timeoutId)
}) as typeof clearTimeout,
getAgentTranscript: async () => ({
messages: transcriptMessagesForTest,
contentReplacements: [],
}),
isPoorModeActive: () => false,
logError: error => {
loggedErrors.push(
error instanceof Error ? error : new Error(String(error)),
)
},
logForDebugging: message => {
debugLogs.push(message)
},
runForkedAgent: async (args: ForkCall) => {
forkCalls.push(args)
return {
messages: [
{
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Reading udsClient.ts' }],
},
},
],
} as unknown as ForkedAgentResult
},
setTimeout: ((callback: TimerHandler) => {
if (typeof callback !== 'function') {
throw new Error('Expected timer callback')
}
scheduledCount += 1
scheduled = callback as () => void | Promise<void>
lastTimerHandle = { id: scheduledCount }
return lastTimerHandle as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout,
updateAgentSummary: (taskId: string, summary: string) => {
updateCalls.push({ taskId, summary })
},
...dependencies,
},
)
}
beforeEach(() => {
forkCalls = []
updateCalls = []
scheduled = undefined
handle = undefined
transcriptMessagesForTest = transcriptMessages
debugLogs = []
loggedErrors = []
clearedHandles = []
scheduledCount = 0
lastTimerHandle = undefined
})
function expectDebugLogContaining(fragment: string): void {
expect(debugLogs.some(message => message.includes(fragment))).toBe(true)
}
test('summarizes bounded transcript once and skips unchanged fingerprints', async () => {
handle = startTestSummarization()
expect(typeof scheduled).toBe('function')
await scheduled!()
expect(forkCalls).toHaveLength(1)
expect(updateCalls).toEqual([
{ taskId: 'task-1', summary: 'Reading udsClient.ts' },
])
const forkContext = forkCalls[0].cacheSafeParams.forkContextMessages ?? []
expect(forkContext.map(message => String(message.uuid))).toEqual([
'u1',
'a1',
'u2',
])
expect(forkContext.some(message => String(message.uuid) === 'old')).toBe(
false,
)
await scheduled!()
expect(forkCalls).toHaveLength(1)
expect(updateCalls).toHaveLength(1)
expect(loggedErrors).toEqual([])
})
test('skips summarization when filtering leaves too little bounded context', async () => {
transcriptMessagesForTest = [
{ type: 'user', message: { content: 'start' }, uuid: 'u1' },
{
type: 'assistant',
uuid: 'a1',
message: {
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
},
},
{ type: 'user', message: { content: 'continue' }, uuid: 'u2' },
] as unknown as Message[]
handle = startTestSummarization()
expect(typeof scheduled).toBe('function')
await scheduled!()
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining(
'[AgentSummary] Skipping summary for task-1: no bounded context available',
)
})
test('skips summarization before building context when transcript is too short', async () => {
transcriptMessagesForTest = transcriptMessages.slice(0, 2)
handle = startTestSummarization()
expect(typeof scheduled).toBe('function')
await scheduled!()
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining(
'[AgentSummary] Skipping summary for task-1: not enough messages (2)',
)
})
test('skips and reschedules while poor mode is active', async () => {
handle = startTestSummarization({
isPoorModeActive: () => true,
})
expect(typeof scheduled).toBe('function')
const initialScheduledCount = scheduledCount
const initialTimerHandle = lastTimerHandle
await scheduled!()
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining('[AgentSummary] Skipping summary — poor mode active')
expect(scheduledCount).toBe(initialScheduledCount + 1)
expect(lastTimerHandle).not.toBe(initialTimerHandle)
})
test('logs summary errors and schedules the next timer', async () => {
const error = new Error('fork failed')
handle = startTestSummarization({
runForkedAgent: async () => {
throw error
},
})
expect(typeof scheduled).toBe('function')
const initialScheduledCount = scheduledCount
const initialTimerHandle = lastTimerHandle
await scheduled!()
expect(loggedErrors).toEqual([error])
expect(updateCalls).toEqual([])
expect(scheduledCount).toBe(initialScheduledCount + 1)
expect(lastTimerHandle).not.toBe(initialTimerHandle)
})
test('stop clears the pending summary timer', () => {
handle = startTestSummarization()
const pendingHandle = lastTimerHandle
handle.stop()
expectDebugLogContaining('[AgentSummary] Stopping summarization for task-1')
expect(clearedHandles).toEqual([pendingHandle])
})
})

View File

@@ -1,268 +0,0 @@
import { describe, expect, test } from 'bun:test'
import type { Message } from '../../../types/message.js'
import {
buildSummaryContext,
estimateMessageChars,
getSummaryContextFingerprint,
MAX_SUMMARY_CONTEXT_CHARS,
selectSummaryContextMessages,
} from '../summaryContext.js'
function makeMessage(
type: 'user' | 'assistant',
uuid: string,
content: string,
): Message {
return {
type,
uuid,
message: {
role: type,
content,
},
} as unknown as Message
}
describe('selectSummaryContextMessages', () => {
test('keeps a bounded recent suffix that starts with a user message', () => {
const messages = [
makeMessage('assistant', 'a0', 'older assistant'),
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'first response'),
makeMessage('user', 'u2', 'second prompt'),
makeMessage('assistant', 'a2', 'second response'),
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 3,
maxChars: 1_000,
})
expect(selected.map(message => String(message.uuid))).toEqual(['u2', 'a2'])
})
test('returns no context when the newest message exceeds the byte budget', () => {
const messages = [
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'x'.repeat(100)),
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 10,
maxChars: 10,
})
expect(selected).toEqual([])
})
test('uses serialized message size for nested content budgets', () => {
const messages = [
makeMessage('user', 'u1', 'first prompt'),
{
...makeMessage('assistant', 'a1', 'short'),
nested: {
payload: Array.from({ length: 50 }, (_value, index) => ({
index,
text: 'x'.repeat(20),
})),
},
} as unknown as Message,
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 10,
maxChars: 200,
})
expect(selected).toEqual([])
})
test('stops at an older oversized message after keeping the recent suffix', () => {
const messages = [
makeMessage('user', 'u1', 'x'.repeat(5_000)),
makeMessage('user', 'u2', 'small prompt'),
makeMessage('assistant', 'a2', 'small answer'),
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 10,
maxChars: 1_000,
})
expect(selected.map(message => String(message.uuid))).toEqual(['u2', 'a2'])
})
test('drops leading orphan tool results after bounding', () => {
const messages = [
makeMessage('assistant', 'a0', 'older assistant'),
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' },
],
},
} as unknown as Message,
makeMessage('assistant', 'a1', 'after orphan'),
makeMessage('user', 'u2', 'next prompt'),
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 3,
maxChars: 1_000,
})
expect(selected.map(message => String(message.uuid))).toEqual(['u2'])
})
})
describe('getSummaryContextFingerprint', () => {
test('estimates circular messages as unbounded', () => {
const circular = makeMessage('assistant', 'a1', 'cycle') as Message & {
self?: unknown
}
circular.self = circular
expect(estimateMessageChars(circular)).toBe(Number.POSITIVE_INFINITY)
})
test('ignores non-json primitive fields in size estimates', () => {
const message = makeMessage('assistant', 'a1', 'metadata') as Message & {
skipUndefined?: undefined
skipFunction?: () => void
skipSymbol?: symbol
}
message.skipUndefined = undefined
message.skipFunction = () => undefined
message.skipSymbol = Symbol('ignored')
expect(estimateMessageChars(message)).toBeGreaterThan(0)
})
test('treats unsupported top-level primitives as zero-size estimates', () => {
expect(
estimateMessageChars((() => undefined) as unknown as Message),
).toBe(0)
expect(estimateMessageChars(1n as unknown as Message)).toBe(0)
})
test('returns null for an empty transcript', () => {
expect(getSummaryContextFingerprint([])).toBeNull()
})
test('changes when the transcript grows', () => {
const messages = [
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'first response'),
]
const first = getSummaryContextFingerprint(messages)
const second = getSummaryContextFingerprint([
...messages,
makeMessage('user', 'u2', 'next prompt'),
])
expect(first?.startsWith('2:a1:')).toBe(true)
expect(second?.startsWith('3:u2:')).toBe(true)
expect(first).not.toBe(second)
})
test('changes when message content changes under the same uuid', () => {
const first = getSummaryContextFingerprint([
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'first response'),
])
const second = getSummaryContextFingerprint([
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'updated response'),
])
expect(first).not.toBe(second)
})
test('includes a truncation marker for oversized primitive values', () => {
const prefix = 'x'.repeat(MAX_SUMMARY_CONTEXT_CHARS + 100)
const first = getSummaryContextFingerprint([
makeMessage('assistant', 'a1', `${prefix}a`),
])
const second = getSummaryContextFingerprint([
makeMessage('assistant', 'a1', `${prefix}b`),
])
expect(first).not.toBe(second)
})
test('fingerprints circular message references without recursing forever', () => {
const circular = makeMessage('assistant', 'a1', 'cycle') as Message & {
self?: unknown
}
circular.self = circular
expect(getSummaryContextFingerprint([circular])).toContain(':a1:')
})
})
describe('buildSummaryContext', () => {
test('returns bounded messages and fingerprint for summarizable context', () => {
const messages = [
{ type: 'user', uuid: 'u1', message: { content: 'start' } },
{
type: 'assistant',
uuid: 'a1',
message: { content: [{ type: 'text', text: 'working' }] },
},
{ type: 'user', uuid: 'u2', message: { content: 'continue' } },
] as unknown as Message[]
const result = buildSummaryContext(messages, null)
expect(result.skipReason).toBeUndefined()
expect(result.messages.map(message => String(message.uuid))).toEqual([
'u1',
'a1',
'u2',
])
expect(result.fingerprint).toContain('3:u2:')
})
test('reports unchanged contexts by fingerprint', () => {
const messages = [
{ type: 'user', uuid: 'u1', message: { content: 'start' } },
{
type: 'assistant',
uuid: 'a1',
message: { content: [{ type: 'text', text: 'working' }] },
},
{ type: 'user', uuid: 'u2', message: { content: 'continue' } },
] as unknown as Message[]
const first = buildSummaryContext(messages, null)
const second = buildSummaryContext(messages, first.fingerprint)
expect(second.skipReason).toBe('unchanged')
expect(second.fingerprint).toBe(first.fingerprint)
})
test('filters incomplete tool calls before deciding context is too small', () => {
const messages = [
{ type: 'user', uuid: 'u1', message: { content: 'start' } },
{
type: 'assistant',
uuid: 'a1',
message: {
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
},
},
{ type: 'user', uuid: 'u2', message: { content: 'continue' } },
] as unknown as Message[]
const result = buildSummaryContext(messages, null)
expect(result.skipReason).toBe('too_small')
expect(result.messages.map(message => String(message.uuid))).toEqual([
'u1',
'u2',
])
})
})

View File

@@ -1,34 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
buildSummaryPrompt,
createSummaryPromptMessage,
} from '../summaryPrompt.js'
describe('buildSummaryPrompt', () => {
test('builds the first summary prompt without previous-summary pressure', () => {
const prompt = buildSummaryPrompt(null)
expect(prompt).toContain('Describe your most recent action')
expect(prompt).toContain('Good: "Reading runAgent.ts"')
expect(prompt).not.toContain('Previous:')
})
test('asks for a new summary when a previous one exists', () => {
const prompt = buildSummaryPrompt('Reading udsMessaging.ts')
expect(prompt).toContain('Previous: "Reading udsMessaging.ts"')
expect(prompt).toContain('say something NEW')
})
})
describe('createSummaryPromptMessage', () => {
test('creates the minimal user message shape used by forked summaries', () => {
const message = createSummaryPromptMessage('Summarize progress')
expect(message.type).toBe('user')
expect(message.message.role).toBe('user')
expect(message.message.content).toBe('Summarize progress')
expect(message.uuid).toBeString()
expect(message.timestamp).toBeString()
})
})

View File

@@ -13,6 +13,7 @@
import type { TaskContext } from '../../Task.js'
import { isPoorModeActive } from '../../commands/poor/poorMode.js'
import { updateAgentSummary } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
import { filterIncompleteToolCalls } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js'
import type { AgentId } from '../../types/ids.js'
import { logForDebugging } from '../../utils/debug.js'
import {
@@ -20,32 +21,34 @@ import {
runForkedAgent,
} from '../../utils/forkedAgent.js'
import { logError } from '../../utils/log.js'
import { createUserMessage } from '../../utils/messages.js'
import { getAgentTranscript } from '../../utils/sessionStorage.js'
import { buildSummaryContext } from './summaryContext.js'
import {
buildSummaryPrompt,
createSummaryPromptMessage,
} from './summaryPrompt.js'
const SUMMARY_INTERVAL_MS = 30_000
export type AgentSummaryDependencies = Partial<{
clearTimeout: typeof clearTimeout
getAgentTranscript: typeof getAgentTranscript
isPoorModeActive: typeof isPoorModeActive
logError: typeof logError
logForDebugging: typeof logForDebugging
runForkedAgent: typeof runForkedAgent
setTimeout: typeof setTimeout
updateAgentSummary: typeof updateAgentSummary
}>
function buildSummaryPrompt(previousSummary: string | null): string {
const prevLine = previousSummary
? `\nPrevious: "${previousSummary}" — say something NEW.\n`
: ''
return `Describe your most recent action in 3-5 words using present tense (-ing). Name the file or function, not the branch. Do not use tools.
${prevLine}
Good: "Reading runAgent.ts"
Good: "Fixing null check in validate.ts"
Good: "Running auth module tests"
Good: "Adding retry logic to fetchUser"
Bad (past tense): "Analyzed the branch diff"
Bad (too vague): "Investigating the issue"
Bad (too long): "Reviewing full branch diff and AgentTool.tsx integration"
Bad (branch name): "Analyzed adam/background-summary branch diff"`
}
export function startAgentSummarization(
taskId: string,
agentId: AgentId,
cacheSafeParams: CacheSafeParams,
setAppState: TaskContext['setAppState'],
dependencies: AgentSummaryDependencies = {},
): { stop: () => void } {
// Drop forkContextMessages from the closure — runSummary rebuilds it each
// tick from getAgentTranscript(). Without this, the original fork messages
@@ -55,67 +58,39 @@ export function startAgentSummarization(
let timeoutId: ReturnType<typeof setTimeout> | null = null
let stopped = false
let previousSummary: string | null = null
let lastHandledTranscriptFingerprint: string | null = null
const clearTimeoutImpl = dependencies.clearTimeout ?? clearTimeout
const getAgentTranscriptImpl =
dependencies.getAgentTranscript ?? getAgentTranscript
const isPoorModeActiveImpl =
dependencies.isPoorModeActive ?? isPoorModeActive
const logErrorImpl = dependencies.logError ?? logError
const logForDebuggingImpl =
dependencies.logForDebugging ?? logForDebugging
const runForkedAgentImpl = dependencies.runForkedAgent ?? runForkedAgent
const setTimeoutImpl = dependencies.setTimeout ?? setTimeout
const updateAgentSummaryImpl =
dependencies.updateAgentSummary ?? updateAgentSummary
async function runSummary(): Promise<void> {
if (stopped) return
if (isPoorModeActiveImpl()) {
logForDebuggingImpl('[AgentSummary] Skipping summary — poor mode active')
if (isPoorModeActive()) {
logForDebugging('[AgentSummary] Skipping summary — poor mode active')
scheduleNext()
return
}
logForDebuggingImpl(`[AgentSummary] Timer fired for agent ${agentId}`)
logForDebugging(`[AgentSummary] Timer fired for agent ${agentId}`)
try {
// Read current messages from transcript
const transcript = await getAgentTranscriptImpl(agentId)
const transcript = await getAgentTranscript(agentId)
if (!transcript || transcript.messages.length < 3) {
// Not enough context yet — finally block will schedule next attempt
logForDebuggingImpl(
logForDebugging(
`[AgentSummary] Skipping summary for ${taskId}: not enough messages (${transcript?.messages.length ?? 0})`,
)
return
}
const summaryContext = buildSummaryContext(
transcript.messages,
lastHandledTranscriptFingerprint,
)
if (summaryContext.skipReason === 'unchanged') {
logForDebuggingImpl(
`[AgentSummary] Skipping summary for ${taskId}: transcript unchanged`,
)
return
}
if (summaryContext.skipReason === 'too_small') {
logForDebuggingImpl(
`[AgentSummary] Skipping summary for ${taskId}: no bounded context available`,
)
return
}
// Filter to clean message state
const cleanMessages = filterIncompleteToolCalls(transcript.messages)
// Build fork params with current messages
const forkParams: CacheSafeParams = {
...baseParams,
forkContextMessages: summaryContext.messages,
forkContextMessages: cleanMessages,
}
logForDebuggingImpl(
`[AgentSummary] Forking for summary, ${summaryContext.messages.length} messages in context`,
logForDebugging(
`[AgentSummary] Forking for summary, ${cleanMessages.length} messages in context`,
)
// Create abort controller for this summary
@@ -137,9 +112,9 @@ export function startAgentSummarization(
// ContentReplacementState is cloned by default in createSubagentContext
// from forkParams.toolUseContext (the subagent's LIVE state captured at
// onCacheSafeParams time). No explicit override needed.
const result = await runForkedAgentImpl({
const result = await runForkedAgent({
promptMessages: [
createSummaryPromptMessage(buildSummaryPrompt(previousSummary)),
createUserMessage({ content: buildSummaryPrompt(previousSummary) }),
],
cacheSafeParams: forkParams,
canUseTool,
@@ -161,24 +136,21 @@ export function startAgentSummarization(
)
continue
}
const contentArr = Array.isArray(msg.message!.content)
? msg.message!.content
: []
const contentArr = Array.isArray(msg.message!.content) ? msg.message!.content : []
const textBlock = contentArr.find(b => b.type === 'text')
if (textBlock?.type === 'text' && textBlock.text.trim()) {
const summaryText = textBlock.text.trim()
logForDebuggingImpl(
logForDebugging(
`[AgentSummary] Summary result for ${taskId}: ${summaryText}`,
)
lastHandledTranscriptFingerprint = summaryContext.fingerprint
previousSummary = summaryText
updateAgentSummaryImpl(taskId, summaryText, setAppState)
updateAgentSummary(taskId, summaryText, setAppState)
break
}
}
} catch (e) {
if (!stopped && e instanceof Error) {
logErrorImpl(e)
logError(e)
}
} finally {
summaryAbortController = null
@@ -191,14 +163,14 @@ export function startAgentSummarization(
function scheduleNext(): void {
if (stopped) return
timeoutId = setTimeoutImpl(runSummary, SUMMARY_INTERVAL_MS)
timeoutId = setTimeout(runSummary, SUMMARY_INTERVAL_MS)
}
function stop(): void {
logForDebuggingImpl(`[AgentSummary] Stopping summarization for ${taskId}`)
logForDebugging(`[AgentSummary] Stopping summarization for ${taskId}`)
stopped = true
if (timeoutId) {
clearTimeoutImpl(timeoutId)
clearTimeout(timeoutId)
timeoutId = null
}
if (summaryAbortController) {

View File

@@ -1,219 +0,0 @@
import { createHash } from 'node:crypto'
import { filterIncompleteToolCalls } from '@claude-code-best/builtin-tools/tools/AgentTool/filterIncompleteToolCalls.js'
import type { Message } from '../../types/message.js'
export const MAX_SUMMARY_CONTEXT_MESSAGES = 120
export const MAX_SUMMARY_CONTEXT_CHARS = 200_000
function estimateJsonChars(
value: unknown,
limit: number,
seen = new Set<object>(),
): number {
if (value === null) return 4
switch (typeof value) {
case 'string':
return value.length + 2
case 'number':
case 'boolean':
return String(value).length
case 'undefined':
case 'function':
case 'symbol':
return 0
case 'object': {
if (seen.has(value)) return Number.POSITIVE_INFINITY
seen.add(value)
let total = 2
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
total += String(index).length + 3
total += estimateJsonChars(value[index], limit - total, seen)
if (total > limit) return total
}
} else {
const record = value as Record<string, unknown>
for (const key in record) {
if (!Object.hasOwn(record, key)) continue
total += key.length + 3
total += estimateJsonChars(record[key], limit - total, seen)
if (total > limit) return total
}
}
seen.delete(value)
return total
}
}
return 0
}
function updateFingerprintHash(
hash: ReturnType<typeof createHash>,
value: unknown,
limit: { remaining: number },
seen = new Set<object>(),
): void {
if (limit.remaining <= 0) return
if (value === null || typeof value !== 'object') {
const text = String(value)
const consumed = Math.min(text.length, limit.remaining)
if (consumed <= 0) return
hash.update(typeof value)
hash.update(':')
hash.update(text.slice(0, consumed))
if (consumed < text.length) {
hash.update(`#truncated:${text.length}:${text.slice(-64)}`)
}
limit.remaining -= consumed
return
}
if (seen.has(value)) {
hash.update('[Circular]')
return
}
seen.add(value)
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
if (limit.remaining <= 0) break
const key = String(index)
hash.update(key)
limit.remaining -= key.length
updateFingerprintHash(hash, value[index], limit, seen)
}
} else {
const record = value as Record<string, unknown>
for (const key in record) {
if (limit.remaining <= 0) break
if (!Object.hasOwn(record, key)) continue
hash.update(key)
limit.remaining -= key.length
updateFingerprintHash(hash, record[key], limit, seen)
}
}
seen.delete(value)
}
export function estimateMessageChars(
message: Message,
limit = Number.POSITIVE_INFINITY,
): number {
const estimated = estimateJsonChars(message, limit)
if (!Number.isFinite(estimated)) {
return Number.POSITIVE_INFINITY
}
return estimated
}
function hasToolResultBlock(message: Message): boolean {
if (message.type !== 'user') return false
const content = message.message?.content
return (
Array.isArray(content) &&
content.some(block => {
return Boolean(
block &&
typeof block === 'object' &&
'type' in block &&
block.type === 'tool_result',
)
})
)
}
export function getSummaryContextFingerprint(
messages: Message[],
): string | null {
const lastMessage = messages.at(-1)
if (!lastMessage) return null
const hash = createHash('sha256')
updateFingerprintHash(hash, messages, {
remaining: MAX_SUMMARY_CONTEXT_CHARS,
})
return `${messages.length}:${lastMessage.uuid}:${hash.digest('hex').slice(0, 16)}`
}
export function selectSummaryContextMessages(
messages: Message[],
limits: {
maxMessages?: number
maxChars?: number
} = {},
): Message[] {
const maxMessages = limits.maxMessages ?? MAX_SUMMARY_CONTEXT_MESSAGES
const maxChars = limits.maxChars ?? MAX_SUMMARY_CONTEXT_CHARS
if (maxMessages <= 0 || maxChars <= 0) return []
const selected: Message[] = []
let selectedChars = 0
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
const messageChars = estimateMessageChars(message, maxChars - selectedChars)
if (messageChars > maxChars) {
if (selected.length === 0) return []
break
}
if (
selected.length >= maxMessages ||
selectedChars + messageChars > maxChars
) {
break
}
selected.unshift(message)
selectedChars += messageChars
}
while (selected.length > 0) {
const first = selected[0]
if (!first) break
if (first.type !== 'user' || hasToolResultBlock(first)) {
selected.shift()
continue
}
break
}
return selected
}
export type SummaryContextBuildResult = {
messages: Message[]
fingerprint: string | null
skipReason?: 'too_small' | 'unchanged'
}
export function buildSummaryContext(
messages: Message[],
previousFingerprint: string | null,
): SummaryContextBuildResult {
const cleanMessages = filterIncompleteToolCalls(messages)
const boundedMessages = filterIncompleteToolCalls(
selectSummaryContextMessages(cleanMessages),
)
const fingerprint = getSummaryContextFingerprint(boundedMessages)
if (fingerprint && fingerprint === previousFingerprint) {
return {
messages: boundedMessages,
fingerprint,
skipReason: 'unchanged',
}
}
if (boundedMessages.length < 3) {
return {
messages: boundedMessages,
fingerprint,
skipReason: 'too_small',
}
}
return {
messages: boundedMessages,
fingerprint,
}
}

View File

@@ -1,32 +0,0 @@
import { randomUUID, type UUID } from 'node:crypto'
import type { UserMessage } from '../../types/message.js'
export function buildSummaryPrompt(previousSummary: string | null): string {
const prevLine = previousSummary
? `\nPrevious: "${previousSummary}" — say something NEW.\n`
: ''
return `Describe your most recent action in 3-5 words using present tense (-ing). Name the file or function, not the branch. Do not use tools.
${prevLine}
Good: "Reading runAgent.ts"
Good: "Fixing null check in validate.ts"
Good: "Running auth module tests"
Good: "Adding retry logic to fetchUser"
Bad (past tense): "Analyzed the branch diff"
Bad (too vague): "Investigating the issue"
Bad (too long): "Reviewing full branch diff and AgentTool.tsx integration"
Bad (branch name): "Analyzed adam/background-summary branch diff"`
}
export function createSummaryPromptMessage(content: string): UserMessage {
return {
type: 'user',
message: {
role: 'user',
content,
},
uuid: randomUUID() as UUID,
timestamp: new Date().toISOString(),
}
}

View File

@@ -1347,6 +1347,12 @@ async function* queryModel(
return
}
if (getAPIProvider() === 'codex') {
const { queryModelCodex } = await import('./codex/index.js')
yield* queryModelCodex(messagesForAPI, systemPrompt, filteredTools, signal, options)
return
}
if (getAPIProvider() === 'gemini') {
const { queryModelGemini } = await import('./gemini/index.js')
yield* queryModelGemini(
@@ -1776,10 +1782,6 @@ async function* queryModel(
// captures only primitives instead of paramsFromContext's full closure scope
// (messagesForAPI, system, allTools, betas — the entire request-building
// context), which would otherwise be pinned until the promise resolves.
// Also capture thinking params for Langfuse observability.
// Pass the entire thinking config object so all fields (type, budget_tokens,
// and any future additions) flow through without cherry-picking.
let langfuseThinking: BetaMessageStreamParams['thinking'] | undefined
{
const queryParams = paramsFromContext({
model: options.model,
@@ -1787,10 +1789,8 @@ async function* queryModel(
})
const logMessagesLength = queryParams.messages.length
const logBetas = useBetas ? (queryParams.betas ?? []) : []
const logThinkingType = queryParams.thinking?.type ?? 'disabled'
const logEffortValue = queryParams.output_config?.effort
if (queryParams.thinking && queryParams.thinking.type !== 'disabled') {
langfuseThinking = queryParams.thinking
}
void options.getToolPermissionContext().then(permissionContext => {
logAPIQuery({
model: options.model,
@@ -1800,7 +1800,7 @@ async function* queryModel(
permissionMode: permissionContext.mode,
querySource: options.querySource,
queryTracking: options.queryTracking,
thinkingConfig,
thinkingType: logThinkingType,
effortValue: logEffortValue,
fastMode: isFastMode,
previousRequestId,
@@ -2551,9 +2551,6 @@ async function* queryModel(
maxOutputTokens,
thinkingType:
thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingConfig.type === 'enabled' && {
thinkingBudgetTokens: thinkingConfig.budgetTokens,
}),
fallback_disabled: true,
request_id: (streamRequestId ??
'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -2586,9 +2583,6 @@ async function* queryModel(
maxOutputTokens,
thinkingType:
thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingConfig.type === 'enabled' && {
thinkingBudgetTokens: thinkingConfig.budgetTokens,
}),
fallback_disabled: false,
request_id: (streamRequestId ??
'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -2705,9 +2699,6 @@ async function* queryModel(
maxOutputTokens,
thinkingType:
thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingConfig.type === 'enabled' && {
thinkingBudgetTokens: thinkingConfig.budgetTokens,
}),
request_id:
failedRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
fallback_cause:
@@ -2940,7 +2931,6 @@ async function* queryModel(
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
thinking: langfuseThinking,
})
void options.getToolPermissionContext().then(permissionContext => {

View File

@@ -0,0 +1,57 @@
import OpenAI from 'openai'
import { openaiAdapter } from 'src/services/providerUsage/adapters/openai.js'
import { updateProviderBuckets } from 'src/services/providerUsage/store.js'
import { getProxyFetchOptions } from 'src/utils/proxy.js'
export const DEFAULT_CODEX_BASE_URL = 'https://api.openai.com/v1'
let cachedClient: OpenAI | null = null
function wrapFetchForUsage(base: typeof fetch): typeof fetch {
const wrapped = async (
...args: Parameters<typeof fetch>
): Promise<Response> => {
const res = await base(...args)
try {
updateProviderBuckets('codex', openaiAdapter.parseHeaders(res.headers))
} catch {
// Usage tracking must not affect the request path.
}
return res
}
return wrapped as unknown as typeof fetch
}
export function getCodexClient(options?: {
maxRetries?: number
fetchOverride?: typeof fetch
}): OpenAI {
if (cachedClient && !options?.fetchOverride) {
return cachedClient
}
const apiKey = process.env.CODEX_API_KEY || process.env.CODEX_ACCESS_TOKEN || ''
const baseURL = process.env.CODEX_BASE_URL || DEFAULT_CODEX_BASE_URL
const baseFetch = options?.fetchOverride ?? (globalThis.fetch as typeof fetch)
const wrappedFetch = wrapFetchForUsage(baseFetch)
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: options?.maxRetries ?? 0,
timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
dangerouslyAllowBrowser: true,
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }),
fetch: wrappedFetch,
})
if (!options?.fetchOverride) {
cachedClient = client
}
return client
}
export function clearCodexClientCache(): void {
cachedClient = null
}

View File

@@ -0,0 +1,115 @@
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
type CodexErrorLike = {
status?: unknown
message?: unknown
error?: {
message?: unknown
}
}
export type NormalizedCodexError = {
content: string
error: SDKAssistantMessageError
}
function readErrorStatus(error: unknown): number | null {
if (
typeof error === 'object' &&
error !== null &&
typeof (error as CodexErrorLike).status === 'number'
) {
return (error as CodexErrorLike).status as number
}
return null
}
function readErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.length > 0) {
return error.message
}
if (typeof error === 'object' && error !== null) {
const value = error as CodexErrorLike
if (typeof value.message === 'string' && value.message.length > 0) {
return value.message
}
if (
typeof value.error?.message === 'string' &&
value.error.message.length > 0
) {
return value.error.message
}
}
return String(error)
}
export function getCodexConfigurationError(): NormalizedCodexError | null {
if (!process.env.CODEX_API_KEY && !process.env.CODEX_ACCESS_TOKEN) {
return {
content:
'Missing CODEX_API_KEY or CODEX_ACCESS_TOKEN. Use /login (ChatGPT Subscription) or set manually.',
error: 'authentication_failed',
}
}
return null
}
export function normalizeCodexError(error: unknown): NormalizedCodexError {
const status = readErrorStatus(error)
const message = readErrorMessage(error)
if (/^Codex preflight:/i.test(message)) {
return {
content: message,
error: 'invalid_request',
}
}
if (status === 401 || status === 403) {
return {
content: `Codex authentication failed (${status}). ${message}`,
error: 'authentication_failed',
}
}
if (status === 404) {
return {
content:
'Codex endpoint not found (404). Verify CODEX_BASE_URL points to a Responses API root.',
error: 'invalid_request',
}
}
if (status === 429) {
return {
content:
'Codex rate limit reached (429). Retry shortly or reduce request volume.',
error: 'rate_limit',
}
}
if (status === 502 && /upstream request failed/i.test(message)) {
return {
content:
'Codex gateway returned 502 Upstream request failed. This usually means a transient gateway issue or incomplete Responses API compatibility during tool replay.',
error: 'server_error',
}
}
if (status !== null && status >= 500) {
return {
content: `Codex server error (${status}): ${message}`,
error: 'server_error',
}
}
return {
content: `API Error: ${message}`,
error: 'unknown',
}
}

View File

@@ -0,0 +1,132 @@
import { createHash } from 'crypto'
import { logForDebugging } from '../../../utils/debug.js'
const resolvedImageUrls = new Map<string, string>()
const DEFAULT_TIMEOUT_MS = 30_000
const IMGBB_UPLOAD_URL = 'https://api.imgbb.com/1/upload'
type ImgbbVariant = {
url?: unknown
}
type ImgbbPayload = {
data?: {
url?: unknown
display_url?: unknown
image?: ImgbbVariant
medium?: ImgbbVariant
thumb?: ImgbbVariant
}
}
function getUploadTimeoutMs(): number {
const raw =
process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS ??
process.env.CODEX_IMAGE_URL_TIMEOUT_MS
if (!raw) {
return DEFAULT_TIMEOUT_MS
}
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS
}
function getCacheKey(prefix: string, value: string): string {
return `${prefix}:${createHash('sha256').update(value).digest('hex')}`
}
function getImgbbApiKey(): string | null {
const apiKey = process.env.CODEX_IMGBB_API_KEY?.trim()
return apiKey && apiKey.length > 0 ? apiKey : null
}
function pickImgbbImageUrl(payload: ImgbbPayload): string | null {
const candidates = [
payload.data?.medium?.url,
payload.data?.thumb?.url,
payload.data?.image?.url,
payload.data?.url,
payload.data?.display_url,
]
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.length > 0) {
return candidate
}
}
return null
}
async function withTimeout<T>(
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), getUploadTimeoutMs())
try {
return await run(controller.signal)
} finally {
clearTimeout(timeout)
}
}
async function uploadToImgbb(
base64Image: string,
): Promise<string | null> {
const apiKey = getImgbbApiKey()
if (!apiKey) {
return null
}
try {
const url = await withTimeout(async signal => {
const body = new FormData()
body.append('image', base64Image)
const response = await fetch(`${IMGBB_UPLOAD_URL}?key=${encodeURIComponent(apiKey)}`, {
method: 'POST',
body,
signal,
})
if (!response.ok) {
logForDebugging(
`[Codex] ImgBB upload failed: ${response.status} ${response.statusText}`,
)
return null
}
return pickImgbbImageUrl((await response.json()) as ImgbbPayload)
})
if (!url) {
logForDebugging('[Codex] ImgBB upload produced no usable URL.')
return null
}
return url
} catch (error) {
logForDebugging(`[Codex] Failed to upload image to ImgBB: ${error}`)
return null
}
}
export async function uploadCodexBase64Image(
data: string,
mediaType: string = 'image/png',
): Promise<string | null> {
const cacheKey = getCacheKey('base64', `${mediaType}:${data}`)
const cached = resolvedImageUrls.get(cacheKey)
if (cached) {
return cached
}
const url = await uploadToImgbb(data)
if (!url) {
return null
}
resolvedImageUrls.set(cacheKey, url)
return url
}

View File

@@ -0,0 +1,304 @@
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type {
Response,
ResponseCreateParamsNonStreaming,
} from 'openai/resources/responses/responses.mjs'
import { appendFileSync } from 'fs'
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
import type {
AssistantMessage,
Message,
StreamEvent,
SystemAPIErrorMessage,
} from '../../../types/message.js'
import type { Tools } from '../../../Tool.js'
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
import { toolToAPISchema } from '../../../utils/api.js'
import {
createAssistantAPIErrorMessage,
normalizeMessagesForAPI,
} from '../../../utils/messages.js'
import { logForDebugging } from '../../../utils/debug.js'
import { getModelMaxOutputTokens } from '../../../utils/context.js'
import type { Options } from '../claude.js'
import { recordLLMObservation } from '../../../services/langfuse/tracing.js'
import {
convertMessagesToLangfuse,
convertOutputToLangfuse,
convertToolsToLangfuse,
} from '../../../services/langfuse/convert.js'
import {
anthropicMessagesToCodexInput,
anthropicToolsToCodex,
resolveCodexMaxTokens,
resolveCodexModel,
} from '@ant/model-provider'
import { getCodexClient } from './client.js'
import { uploadCodexBase64Image } from './imageUpload.js'
import {
getCodexConfigurationError,
normalizeCodexError,
} from './errors.js'
import { sanitizeCodexRequest } from './preflight.js'
import {
addCodexUsage,
type CodexStreamResult,
type CodexUsage,
rawAssistantBlocksToAssistantMessage,
type RawAssistantBlock,
streamCodexAttempt,
} from './streaming.js'
const MAX_CODEX_CONTINUATIONS = 3
function dumpCodexPayload(
body: ResponseCreateParamsNonStreaming,
): void {
const path = process.env.CODEX_DEBUG_PAYLOADS
if (!path) {
return
}
appendFileSync(
path,
`${JSON.stringify({ timestamp: new Date().toISOString(), body }, null, 2)}\n`,
)
}
function appendRawAssistantBlocks(
target: RawAssistantBlock[],
source: RawAssistantBlock[],
): void {
for (const block of source) {
const lastBlock = target.at(-1)
if (lastBlock?.type === 'text' && block.type === 'text') {
lastBlock.text += block.text
continue
}
if (
lastBlock?.type === 'tool_use' &&
block.type === 'tool_use' &&
lastBlock.id === block.id &&
lastBlock.name === block.name &&
block.input.startsWith(lastBlock.input)
) {
lastBlock.input = block.input
continue
}
target.push({ ...block })
}
}
export async function* queryModelCodex(
messages: Message[],
systemPrompt: SystemPrompt,
tools: Tools,
signal: AbortSignal,
options: Options,
): AsyncGenerator<
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
void
> {
try {
const configurationError = getCodexConfigurationError()
if (configurationError) {
yield createAssistantAPIErrorMessage({
content: configurationError.content,
apiError: 'api_error',
error: configurationError.error,
})
return
}
const model = resolveCodexModel(options.model)
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
const toolSchemas = await Promise.all(
tools.map(tool =>
toolToAPISchema(tool, {
getToolPermissionContext: options.getToolPermissionContext,
tools,
agents: options.agents,
allowedAgentTypes: options.allowedAgentTypes,
model: options.model,
}),
),
)
const codexTools = anthropicToolsToCodex(toolSchemas as BetaToolUnion[])
const { upperLimit } = getModelMaxOutputTokens(model)
const maxTokens = resolveCodexMaxTokens(
upperLimit,
options.maxOutputTokensOverride,
)
const client = getCodexClient({
maxRetries: 0,
fetchOverride: options.fetchOverride as typeof fetch | undefined,
})
const start = Date.now()
const collectedMessages: AssistantMessage[] = []
let totalUsage: CodexUsage = {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}
const aggregateBlocks: RawAssistantBlock[] = []
let replayMessages = messagesForAPI
let partialMessage: AssistantMessage['message'] | undefined
let finalResponse: Response | undefined
let terminalIncompleteResponse: Response | undefined
for (
let attempt = 0;
attempt <= MAX_CODEX_CONTINUATIONS;
attempt += 1
) {
const input = await anthropicMessagesToCodexInput(replayMessages, {
resolveBase64ImageUrl: uploadCodexBase64Image,
})
const requestBody = sanitizeCodexRequest({
model,
input,
store: false,
parallel_tool_calls: false,
max_output_tokens: maxTokens,
...(systemPrompt.length > 0 && {
instructions: systemPrompt.join('\n\n'),
}),
...(codexTools.length > 0 && {
tools: codexTools,
}),
...(options.temperatureOverride !== undefined && {
temperature: options.temperatureOverride,
}),
} satisfies ResponseCreateParamsNonStreaming)
if (attempt === 0) {
logForDebugging(
`[Codex] Calling model=${model}, inputItems=${input.length}, tools=${codexTools.length}`,
)
dumpCodexPayload(requestBody)
} else {
logForDebugging(
`[Codex] Continuing incomplete response attempt ${attempt}/${MAX_CODEX_CONTINUATIONS}`,
)
}
const attemptStream = streamCodexAttempt({
client,
requestBody,
signal,
start,
emitPrimaryEvents: attempt === 0,
})
let attemptResult: CodexStreamResult | undefined
while (true) {
const next = await attemptStream.next()
if (next.done) {
attemptResult = next.value
break
}
yield next.value
}
if (!attemptResult?.response) {
continue
}
partialMessage = partialMessage ?? attemptResult.partialMessage
finalResponse = attemptResult.response
terminalIncompleteResponse = attemptResult.incompleteResponse
totalUsage = addCodexUsage(totalUsage, attemptResult.response)
if (attemptResult.assistantBlocks.length === 0) {
break
}
appendRawAssistantBlocks(aggregateBlocks, attemptResult.assistantBlocks)
const shouldContinue =
attemptResult.incompleteResponse !== undefined &&
attempt < MAX_CODEX_CONTINUATIONS
if (!shouldContinue) {
break
}
const continuationMessage = rawAssistantBlocksToAssistantMessage(
attemptResult.assistantBlocks,
attemptResult.response,
tools,
options.agentId,
)
replayMessages = [...replayMessages, continuationMessage]
}
if (finalResponse) {
if (aggregateBlocks.length === 0) {
yield createAssistantAPIErrorMessage({
content: 'Codex returned an empty streamed response.',
apiError: 'api_error',
error: 'unknown',
})
return
}
const assistantMessage = rawAssistantBlocksToAssistantMessage(
aggregateBlocks,
finalResponse,
tools,
options.agentId,
)
assistantMessage.message.usage = totalUsage as any
collectedMessages.push(assistantMessage)
yield assistantMessage
recordLLMObservation(options.langfuseTrace ?? null, {
model,
provider: process.env.CODEX_LOGIN_METHOD === 'chatgpt_subscription'
? 'codex-chatgpt'
: 'codex',
input: convertMessagesToLangfuse(messagesForAPI, systemPrompt),
output: convertOutputToLangfuse(collectedMessages),
usage: totalUsage,
startTime: new Date(start),
endTime: new Date(),
completionStartTime:
partialMessage !== undefined ? new Date(start) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
})
} else {
yield createAssistantAPIErrorMessage({
content: 'Codex returned an empty streamed response.',
apiError: 'api_error',
error: 'unknown',
})
return
}
if (
terminalIncompleteResponse?.incomplete_details?.reason ===
'max_output_tokens'
) {
yield createAssistantAPIErrorMessage({
content: `Output truncated: response exceeded the ${maxTokens} token limit. Set CODEX_MAX_TOKENS or CLAUDE_CODE_MAX_OUTPUT_TOKENS to override.`,
apiError: 'max_output_tokens',
error: 'max_output_tokens' as unknown as SDKAssistantMessageError,
})
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const normalizedError = normalizeCodexError(error)
logForDebugging(`[Codex] Error: ${errorMessage}`, { level: 'error' })
yield createAssistantAPIErrorMessage({
content: normalizedError.content,
apiError: 'api_error',
error: normalizedError.error,
})
}
}

View File

@@ -0,0 +1,151 @@
import type {
ResponseCreateParamsNonStreaming,
ResponseCreateParamsStreaming,
ResponseInputItem,
Tool,
} from 'openai/resources/responses/responses.mjs'
import { normalizeCodexCallId } from '@ant/model-provider'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function assertString(value: unknown, label: string): string {
if (typeof value !== 'string') {
throw new Error(`Codex preflight: ${label} must be a string.`)
}
return value
}
function sanitizeMessageItem(item: Record<string, unknown>): ResponseInputItem {
const role = assertString(item.role, 'message.role')
const content = item.content
if ((role !== 'user' && role !== 'assistant') || !Array.isArray(content)) {
throw new Error('Codex preflight: message items require role and content array.')
}
return item as unknown as ResponseInputItem
}
function sanitizeFunctionCallItem(item: Record<string, unknown>): ResponseInputItem {
const callId = normalizeCodexCallId(item.call_id)
const name = assertString(item.name, 'function_call.name').trim()
const argumentsValue = item.arguments
if (!callId) {
throw new Error('Codex preflight: function_call.call_id is required.')
}
if (name.length === 0) {
throw new Error('Codex preflight: function_call.name is required.')
}
if (typeof argumentsValue !== 'string') {
throw new Error('Codex preflight: function_call.arguments must be a string.')
}
return {
...item,
call_id: callId,
name,
arguments: argumentsValue,
} as ResponseInputItem
}
function sanitizeFunctionCallOutputItem(
item: Record<string, unknown>,
): ResponseInputItem {
const callId = normalizeCodexCallId(item.call_id)
const output = item.output
if (!callId) {
throw new Error('Codex preflight: function_call_output.call_id is required.')
}
if (
typeof output !== 'string' &&
!(Array.isArray(output) && output.every(part => isRecord(part)))
) {
throw new Error(
'Codex preflight: function_call_output.output must be a string or content array.',
)
}
return {
...item,
call_id: callId,
} as ResponseInputItem
}
function sanitizeInputItem(item: unknown): ResponseInputItem {
if (!isRecord(item) || typeof item.type !== 'string') {
throw new Error('Codex preflight: each input item requires a type.')
}
switch (item.type) {
case 'message':
return sanitizeMessageItem(item)
case 'function_call':
return sanitizeFunctionCallItem(item)
case 'function_call_output':
return sanitizeFunctionCallOutputItem(item)
default:
throw new Error(`Codex preflight: unsupported input item type "${item.type}".`)
}
}
function sanitizeTool(tool: unknown): Tool {
if (!isRecord(tool) || tool.type !== 'function') {
throw new Error('Codex preflight: only function tools are supported.')
}
const name = assertString(tool.name, 'tool.name').trim()
const parameters = isRecord(tool.parameters) ? tool.parameters : {}
if (name.length === 0) {
throw new Error('Codex preflight: tool.name is required.')
}
return {
...tool,
type: 'function',
name,
parameters,
} as Tool
}
export function sanitizeCodexRequest(
request: ResponseCreateParamsNonStreaming,
): ResponseCreateParamsNonStreaming {
if (typeof request.model !== 'string' || request.model.trim().length === 0) {
throw new Error('Codex preflight: model is required.')
}
if (
request.instructions !== undefined &&
request.instructions !== null &&
typeof request.instructions !== 'string'
) {
throw new Error('Codex preflight: instructions must be a string.')
}
if (!Array.isArray(request.input)) {
throw new Error('Codex preflight: input must be an array.')
}
return {
...request,
model: request.model.trim(),
instructions: request.instructions?.trim() || undefined,
input: request.input.map(sanitizeInputItem),
tools: request.tools?.map(sanitizeTool),
}
}
export function toStreamingCodexRequest(
request: ResponseCreateParamsNonStreaming,
): ResponseCreateParamsStreaming {
return {
...request,
stream: true,
}
}

View File

@@ -0,0 +1,681 @@
import { randomUUID } from 'crypto'
import type {
Response,
ResponseCreateParamsNonStreaming,
ResponseFunctionToolCall,
ResponseOutputItem,
ResponseOutputMessage,
ResponseStreamEvent,
} from 'openai/resources/responses/responses.mjs'
import type { AssistantMessage, StreamEvent } from '../../../types/message.js'
import type { Tools } from '../../../Tool.js'
import {
createAssistantMessage,
normalizeContentFromAPI,
} from '../../../utils/messages.js'
import { getCodexClient } from './client.js'
import { resolveCodexCallId } from '@ant/model-provider'
import { toStreamingCodexRequest } from './preflight.js'
export type RawAssistantBlock =
| { type: 'text'; text: string }
| { type: 'tool_use'; id: string; name: string; input: string }
export type CodexUsage = {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
}
export type CodexStreamResult = {
response?: Response
incompleteResponse?: Response
partialMessage?: AssistantMessage['message']
assistantBlocks: RawAssistantBlock[]
}
type CodexStreamState = {
contentBlocks: Record<number, RawAssistantBlock>
completedBlocks: Array<RawAssistantBlock | undefined>
partialMessage?: AssistantMessage['message']
finalResponse?: Response
incompleteResponse?: Response
failedResponse?: Response
}
export function getCodexUsage(
response: Pick<Response, 'usage'> | null | undefined,
): CodexUsage {
return {
input_tokens: response?.usage?.input_tokens ?? 0,
output_tokens: response?.usage?.output_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens:
response?.usage?.input_tokens_details.cached_tokens ?? 0,
}
}
export function addCodexUsage(
total: CodexUsage,
response: Pick<Response, 'usage'> | null | undefined,
): CodexUsage {
const usage = getCodexUsage(response)
return {
input_tokens: total.input_tokens + usage.input_tokens,
output_tokens: total.output_tokens + usage.output_tokens,
cache_creation_input_tokens:
total.cache_creation_input_tokens + usage.cache_creation_input_tokens,
cache_read_input_tokens:
total.cache_read_input_tokens + usage.cache_read_input_tokens,
}
}
function createPartialAssistantMessage(
response: Response,
): AssistantMessage['message'] {
return {
id: response.id,
type: 'message',
role: 'assistant',
content: [],
model: response.model,
stop_reason: null,
stop_sequence: null,
usage: getCodexUsage(response) as any,
} as AssistantMessage['message']
}
function createToolUseBlock(
item: Partial<ResponseFunctionToolCall> & { id?: string },
): RawAssistantBlock {
return {
type: 'tool_use',
id: resolveCodexCallId(
item.call_id ?? item.id,
`tool:${item.name ?? ''}:${item.arguments ?? ''}:${item.id ?? ''}`,
),
name: item.name ?? '',
input: item.arguments ?? '',
}
}
function getCompletedTextFromItem(item: ResponseOutputItem): string | null {
if (item.type !== 'message' || item.role !== 'assistant') {
return null
}
for (const content of (item as ResponseOutputMessage).content) {
if (content.type === 'output_text' && content.text.length > 0) {
return content.text
}
if (content.type === 'refusal' && content.refusal.length > 0) {
return content.refusal
}
}
return null
}
function getCompletedAssistantBlocks(
blocks: Array<RawAssistantBlock | undefined>,
): RawAssistantBlock[] {
return blocks.filter(
(block): block is RawAssistantBlock => block !== undefined,
)
}
function getCodexStopReason(
response: Pick<Response, 'incomplete_details'>,
blocks: RawAssistantBlock[],
): string {
if (response.incomplete_details?.reason === 'max_output_tokens') {
return 'max_tokens'
}
return blocks.some(block => block.type === 'tool_use') ? 'tool_use' : 'end_turn'
}
function emitTrailingTextDelta(
output: StreamEvent[],
index: number,
currentText: string,
finalText: string,
): void {
if (!finalText.startsWith(currentText)) {
return
}
const delta = finalText.slice(currentText.length)
if (delta.length === 0) {
return
}
output.push({
type: 'stream_event',
event: {
type: 'content_block_delta',
index,
delta: {
type: 'text_delta',
text: delta,
},
} as any,
} as StreamEvent)
}
function emitTrailingToolDelta(
output: StreamEvent[],
index: number,
currentInput: string,
finalInput: string,
): void {
if (!finalInput.startsWith(currentInput)) {
return
}
const delta = finalInput.slice(currentInput.length)
if (delta.length === 0) {
return
}
output.push({
type: 'stream_event',
event: {
type: 'content_block_delta',
index,
delta: {
type: 'input_json_delta',
partial_json: delta,
},
} as any,
} as StreamEvent)
}
function responseToRawAssistantBlocks(response: Response): RawAssistantBlock[] {
const blocks: RawAssistantBlock[] = []
for (const item of response.output) {
if (item.type === 'function_call') {
const functionCall = item as ResponseFunctionToolCall
blocks.push({
type: 'tool_use',
id: resolveCodexCallId(
functionCall.call_id,
`output:${functionCall.name}:${functionCall.arguments}`,
),
name: functionCall.name,
input: functionCall.arguments,
})
continue
}
if (item.type !== 'message' || item.role !== 'assistant') {
continue
}
for (const content of (item as ResponseOutputMessage).content) {
if (content.type === 'output_text' && content.text.length > 0) {
blocks.push({
type: 'text',
text: content.text,
})
} else if (content.type === 'refusal' && content.refusal.length > 0) {
blocks.push({
type: 'text',
text: content.refusal,
})
}
}
}
if (
blocks.length === 0 &&
typeof response.output_text === 'string' &&
response.output_text.length > 0
) {
blocks.push({
type: 'text',
text: response.output_text,
})
}
return blocks
}
export function rawAssistantBlocksToAssistantMessage(
rawBlocks: RawAssistantBlock[],
response: Pick<Response, 'id' | 'model' | 'usage' | 'incomplete_details'>,
tools: Tools,
agentId?: string,
): AssistantMessage {
const content = normalizeContentFromAPI(
rawBlocks as any,
tools,
agentId as any,
)
const assistantMessage = createAssistantMessage({
content: content as any,
usage: {
input_tokens: response.usage?.input_tokens ?? 0,
output_tokens: response.usage?.output_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens:
response.usage?.input_tokens_details.cached_tokens ?? 0,
} as any,
})
assistantMessage.message.id = response.id
assistantMessage.message.model = response.model
assistantMessage.message.stop_reason = getCodexStopReason(response, rawBlocks) as any
assistantMessage.message.stop_sequence = null
assistantMessage.uuid = randomUUID()
assistantMessage.timestamp = new Date().toISOString()
return assistantMessage
}
function handleCodexStreamEvent(params: {
event: ResponseStreamEvent
partialMessage: AssistantMessage['message'] | undefined
contentBlocks: Record<number, RawAssistantBlock>
completedBlocks: Array<RawAssistantBlock | undefined>
start: number
}): {
output: StreamEvent[]
partialMessage: AssistantMessage['message'] | undefined
finalResponse?: Response
failedResponse?: Response
incompleteResponse?: Response
} {
const { event, start } = params
const output: StreamEvent[] = []
const contentBlocks = params.contentBlocks
const completedBlocks = params.completedBlocks
let partialMessage = params.partialMessage
let finalResponse: Response | undefined
let failedResponse: Response | undefined
let incompleteResponse: Response | undefined
const ensureMessageStart = (response: Response): void => {
if (partialMessage) {
return
}
partialMessage = createPartialAssistantMessage(response)
output.push({
type: 'stream_event',
event: {
type: 'message_start',
message: partialMessage,
} as any,
ttftMs: Date.now() - start,
} as StreamEvent)
}
const ensureTextBlock = (index: number): RawAssistantBlock => {
const existing = contentBlocks[index]
if (existing) {
return existing
}
const block: RawAssistantBlock = { type: 'text', text: '' }
contentBlocks[index] = block
output.push({
type: 'stream_event',
event: {
type: 'content_block_start',
index,
content_block: { type: 'text', text: '' },
} as any,
} as StreamEvent)
return block
}
const ensureToolUseBlock = (
index: number,
item?: Partial<ResponseFunctionToolCall> & { id?: string },
): RawAssistantBlock => {
const existing = contentBlocks[index]
if (existing) {
return existing
}
const block = createToolUseBlock(item ?? {})
contentBlocks[index] = block
const toolBlock = block as Extract<RawAssistantBlock, { type: 'tool_use' }>
output.push({
type: 'stream_event',
event: {
type: 'content_block_start',
index,
content_block: {
type: 'tool_use',
id: toolBlock.id,
name: toolBlock.name,
input: '',
},
} as any,
} as StreamEvent)
return block
}
const emitCompletedBlock = (index: number): void => {
const block = contentBlocks[index]
if (!block) {
return
}
completedBlocks[index] = { ...block }
output.push({
type: 'stream_event',
event: {
type: 'content_block_stop',
index,
} as any,
} as StreamEvent)
delete contentBlocks[index]
}
switch (event.type) {
case 'response.created':
case 'response.in_progress':
ensureMessageStart(event.response)
break
case 'response.output_item.added':
if (event.item.type === 'function_call') {
ensureToolUseBlock(event.output_index, event.item)
} else if (event.item.type === 'message' && event.item.role === 'assistant') {
ensureTextBlock(event.output_index)
}
break
case 'response.output_text.delta':
case 'response.refusal.delta': {
const block = ensureTextBlock(event.output_index)
if (block.type === 'text') {
block.text += event.delta
}
output.push({
type: 'stream_event',
event: {
type: 'content_block_delta',
index: event.output_index,
delta: {
type: 'text_delta',
text: event.delta,
},
} as any,
} as StreamEvent)
break
}
case 'response.function_call_arguments.delta': {
const block = ensureToolUseBlock(event.output_index, { id: event.item_id })
if (block.type === 'tool_use') {
block.input += event.delta
}
output.push({
type: 'stream_event',
event: {
type: 'content_block_delta',
index: event.output_index,
delta: {
type: 'input_json_delta',
partial_json: event.delta,
},
} as any,
} as StreamEvent)
break
}
case 'response.output_text.done':
case 'response.refusal.done': {
const block = ensureTextBlock(event.output_index)
const finalText = event.type === 'response.output_text.done'
? event.text
: event.refusal
if (block.type === 'text') {
emitTrailingTextDelta(output, event.output_index, block.text, finalText)
block.text = finalText
}
emitCompletedBlock(event.output_index)
break
}
case 'response.function_call_arguments.done': {
const block = ensureToolUseBlock(event.output_index, {
id: event.item_id,
name: event.name,
})
if (block.type === 'tool_use') {
if (event.name) {
block.name = event.name
}
emitTrailingToolDelta(output, event.output_index, block.input, event.arguments)
block.input = event.arguments
}
emitCompletedBlock(event.output_index)
break
}
case 'response.output_item.done':
if (
event.item.type === 'message' &&
event.item.role === 'assistant' &&
contentBlocks[event.output_index]
) {
const finalText = getCompletedTextFromItem(event.item)
if (finalText !== null) {
const block = contentBlocks[event.output_index]
if (block.type === 'text') {
emitTrailingTextDelta(output, event.output_index, block.text, finalText)
block.text = finalText
}
}
emitCompletedBlock(event.output_index)
} else if (
event.item.type === 'function_call' &&
contentBlocks[event.output_index]
) {
const block = contentBlocks[event.output_index]
if (block.type === 'tool_use') {
block.id = resolveCodexCallId(
event.item.call_id,
`done:${event.item.name}:${event.item.arguments}:${event.item.id}`,
)
block.name = event.item.name
emitTrailingToolDelta(
output,
event.output_index,
block.input,
event.item.arguments,
)
block.input = event.item.arguments
}
emitCompletedBlock(event.output_index)
}
break
case 'response.completed':
case 'response.incomplete': {
ensureMessageStart(event.response)
if (event.type === 'response.completed') {
finalResponse = event.response
} else {
incompleteResponse = event.response
}
const assistantBlocks = getCompletedAssistantBlocks(completedBlocks)
output.push({
type: 'stream_event',
event: {
type: 'message_delta',
delta: {
stop_reason: getCodexStopReason(event.response, assistantBlocks),
stop_sequence: null,
},
usage: getCodexUsage(event.response),
} as any,
} as StreamEvent)
output.push({
type: 'stream_event',
event: {
type: 'message_stop',
} as any,
} as StreamEvent)
break
}
case 'response.failed':
failedResponse = event.response
break
case 'error':
throw new Error(event.message)
}
return {
output,
partialMessage,
finalResponse,
failedResponse,
incompleteResponse,
}
}
function selectResponse(
state: CodexStreamState,
streamedResponse?: Response,
): CodexStreamResult {
const response =
[streamedResponse, state.finalResponse, state.incompleteResponse, state.failedResponse]
.find(
candidate =>
candidate !== undefined &&
responseToRawAssistantBlocks(candidate).length > 0,
) ??
streamedResponse ??
state.finalResponse ??
state.incompleteResponse ??
state.failedResponse
return {
response,
incompleteResponse: state.incompleteResponse,
partialMessage: state.partialMessage,
assistantBlocks:
response !== undefined && responseToRawAssistantBlocks(response).length > 0
? responseToRawAssistantBlocks(response)
: getCompletedAssistantBlocks(state.completedBlocks),
}
}
async function consumeCodexStream(
events: AsyncIterable<ResponseStreamEvent>,
start: number,
): Promise<CodexStreamState> {
const state: CodexStreamState = {
contentBlocks: {},
completedBlocks: [],
}
for await (const event of events) {
const handled = handleCodexStreamEvent({
event,
partialMessage: state.partialMessage,
contentBlocks: state.contentBlocks,
completedBlocks: state.completedBlocks,
start,
})
state.partialMessage = handled.partialMessage
state.finalResponse = handled.finalResponse ?? state.finalResponse
state.incompleteResponse =
handled.incompleteResponse ?? state.incompleteResponse
state.failedResponse = handled.failedResponse ?? state.failedResponse
}
return state
}
export async function* streamCodexAttempt(params: {
client: ReturnType<typeof getCodexClient>
requestBody: ResponseCreateParamsNonStreaming
signal: AbortSignal
start: number
emitPrimaryEvents?: boolean
}): AsyncGenerator<StreamEvent, CodexStreamResult, void> {
let primaryError: unknown
let primaryResult: CodexStreamResult | undefined
try {
const stream = params.client.responses.stream(
params.requestBody as unknown as Parameters<
typeof params.client.responses.stream
>[0],
{ signal: params.signal },
)
const state: CodexStreamState = {
contentBlocks: {},
completedBlocks: [],
}
for await (const event of stream) {
const handled = handleCodexStreamEvent({
event,
partialMessage: state.partialMessage,
contentBlocks: state.contentBlocks,
completedBlocks: state.completedBlocks,
start: params.start,
})
state.partialMessage = handled.partialMessage
state.finalResponse = handled.finalResponse ?? state.finalResponse
state.incompleteResponse =
handled.incompleteResponse ?? state.incompleteResponse
state.failedResponse = handled.failedResponse ?? state.failedResponse
if (params.emitPrimaryEvents !== false) {
yield* handled.output
}
}
let streamedResponse: Response | undefined
try {
streamedResponse = await stream.finalResponse()
} catch {
streamedResponse = undefined
}
primaryResult = selectResponse(state, streamedResponse)
if (primaryResult.assistantBlocks.length > 0 || primaryResult.response) {
return primaryResult
}
} catch (error) {
primaryError = error
}
try {
const fallbackStream = await params.client.responses.create(
toStreamingCodexRequest(params.requestBody),
{ signal: params.signal },
)
const fallbackState = await consumeCodexStream(
fallbackStream as AsyncIterable<ResponseStreamEvent>,
params.start,
)
const fallbackResult = selectResponse(fallbackState)
if (fallbackResult.assistantBlocks.length > 0 || fallbackResult.response) {
return fallbackResult
}
} catch (fallbackError) {
if (primaryError) {
throw primaryError
}
throw fallbackError
}
if (primaryError) {
throw primaryError
}
return primaryResult ?? {
assistantBlocks: [],
}
}

View File

@@ -193,15 +193,6 @@ export async function* queryModelGemini(
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
thinking:
thinkingConfig.type !== 'disabled'
? {
type: thinkingConfig.type,
...(thinkingConfig.type === 'enabled' && {
budgetTokens: thinkingConfig.budgetTokens,
}),
}
: undefined,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)

View File

@@ -23,7 +23,6 @@ import { getAPIProviderForStatsig } from 'src/utils/model/providers.js'
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import { logOTelEvent } from 'src/utils/telemetry/events.js'
import type { ThinkingConfig } from 'src/utils/thinking.js'
import {
endLLMRequestSpan,
isBetaTracingEnabled,
@@ -177,7 +176,7 @@ export function logAPIQuery({
permissionMode,
querySource,
queryTracking,
thinkingConfig,
thinkingType,
effortValue,
fastMode,
previousRequestId,
@@ -189,13 +188,11 @@ export function logAPIQuery({
permissionMode?: PermissionMode
querySource: string
queryTracking?: QueryChainTracking
thinkingConfig?: ThinkingConfig
thinkingType?: 'adaptive' | 'enabled' | 'disabled'
effortValue?: EffortLevel | null
fastMode?: boolean
previousRequestId?: string | null
}): void {
const thinkingType = thinkingConfig?.type ?? 'disabled'
const thinkingBudgetTokens = thinkingConfig?.type === 'enabled' ? thinkingConfig.budgetTokens : undefined
logEvent('tengu_api_query', {
model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
messagesLength,
@@ -222,9 +219,6 @@ export function logAPIQuery({
: {}),
thinkingType:
thinkingType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingBudgetTokens !== undefined && {
thinkingBudgetTokens,
}),
effortValue:
effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
fastMode,

View File

@@ -418,7 +418,6 @@ export async function* queryModelOpenAI(
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
...(enableThinking && { thinking: { type: 'enabled' } }),
})
// Safety: if stream ended without message_stop, assemble and yield whatever we have

View File

@@ -57,6 +57,8 @@ const PROVIDER_GENERATION_NAMES: Record<string, string> = {
vertex: 'ChatVertexAnthropic',
foundry: 'ChatFoundry',
openai: 'ChatOpenAI',
codex: 'ChatCodex',
'codex-chatgpt': 'ChatCodex',
gemini: 'ChatGoogleGenerativeAI',
grok: 'ChatXAI',
}
@@ -78,16 +80,6 @@ export function recordLLMObservation(
endTime?: Date
completionStartTime?: Date
tools?: unknown
/** Thinking depth configuration used for this request.
* Accepts the full API thinking config object. Fields:
* - type: thinking mode ("enabled", "adaptive", "disabled")
* - budget_tokens (snake_case, from Anthropic API) or budgetTokens (camelCase)
*/
thinking?: {
type: string
budget_tokens?: number
budgetTokens?: number
}
},
): void {
if (!rootSpan || !isLangfuseEnabled()) return
@@ -107,7 +99,6 @@ export function recordLLMObservation(
metadata: {
provider: params.provider,
model: params.model,
...(params.thinking && { thinking: params.thinking }),
},
...(params.completionStartTime && { completionStartTime: params.completionStartTime }),
},

View File

@@ -0,0 +1,238 @@
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
import {
_internal,
performOpenAICodexLogin,
} from '../openai-codex.js'
describe('openai-codex OAuth', () => {
describe('constants', () => {
test('has correct OAuth endpoints', () => {
expect(_internal.CLIENT_ID).toBe('app_EMoamEEZ73f0CkXaXp7hrann')
expect(_internal.AUTHORIZE_URL).toBe('https://auth.openai.com/oauth/authorize')
expect(_internal.TOKEN_URL).toBe('https://auth.openai.com/oauth/token')
expect(_internal.REDIRECT_URI).toBe('http://localhost:1455/auth/callback')
expect(_internal.SCOPE).toBe('openid profile email offline_access api.connectors.read api.connectors.invoke')
})
})
describe('buildAuthorizeUrl', () => {
test('builds correct authorize URL with all parameters', () => {
const url = _internal.buildAuthorizeUrl('test-challenge', 'test-state')
const parsed = new URL(url)
expect(parsed.origin + parsed.pathname).toBe('https://auth.openai.com/oauth/authorize')
expect(parsed.searchParams.get('response_type')).toBe('code')
expect(parsed.searchParams.get('client_id')).toBe(_internal.CLIENT_ID)
expect(parsed.searchParams.get('redirect_uri')).toBe(_internal.REDIRECT_URI)
expect(parsed.searchParams.get('scope')).toBe(_internal.SCOPE)
expect(parsed.searchParams.get('code_challenge')).toBe('test-challenge')
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256')
expect(parsed.searchParams.get('state')).toBe('test-state')
expect(parsed.searchParams.get('id_token_add_organizations')).toBe('true')
expect(parsed.searchParams.get('codex_cli_simplified_flow')).toBe('true')
expect(parsed.searchParams.get('originator')).toBe('claude-code')
})
test('uses custom redirect URI when provided', () => {
const url = _internal.buildAuthorizeUrl('challenge', 'state', 'http://localhost:9999/custom')
const parsed = new URL(url)
expect(parsed.searchParams.get('redirect_uri')).toBe('http://localhost:9999/custom')
})
})
describe('decodeJwt', () => {
test('decodes valid JWT payload', () => {
// Create a minimal JWT: header.payload.signature
const payload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': { chatgpt_account_id: 'acc_12345' },
sub: 'user_123',
}),
).toString('base64url')
const token = `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`
const result = _internal.decodeJwt(token)
expect(result).not.toBeNull()
expect(result?.['https://api.openai.com/auth']?.chatgpt_account_id).toBe('acc_12345')
})
test('returns null for invalid JWT', () => {
expect(_internal.decodeJwt('not-a-jwt')).toBeNull()
expect(_internal.decodeJwt('a.b')).toBeNull()
expect(_internal.decodeJwt('')).toBeNull()
})
})
describe('getAccountId', () => {
test('extracts account ID from valid token', () => {
const payload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': { chatgpt_account_id: 'acc_test123' },
}),
).toString('base64url')
const token = `header.${payload}.sig`
expect(_internal.getAccountId(token)).toBe('acc_test123')
})
test('returns null when account ID is missing', () => {
const payload = Buffer.from(JSON.stringify({ sub: 'user_123' })).toString('base64url')
const token = `header.${payload}.sig`
expect(_internal.getAccountId(token)).toBeNull()
})
test('returns null for empty account ID', () => {
const payload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': { chatgpt_account_id: '' },
}),
).toString('base64url')
const token = `header.${payload}.sig`
expect(_internal.getAccountId(token)).toBeNull()
})
test('returns null for invalid token', () => {
expect(_internal.getAccountId('invalid')).toBeNull()
})
})
describe('exchangeCodeForTokens', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test('exchanges code for tokens successfully', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
id_token: 'id_token_value',
access_token: 'access_value',
refresh_token: 'refresh_value',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
) as any
const result = await _internal.exchangeCodeForTokens('auth_code', 'verifier')
expect(result.access_token).toBe('access_value')
expect(result.refresh_token).toBe('refresh_value')
expect(result.id_token).toBe('id_token_value')
})
test('throws on non-200 response', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response('Unauthorized', { status: 401 }),
),
) as any
await expect(
_internal.exchangeCodeForTokens('bad_code', 'verifier'),
).rejects.toThrow('Token exchange failed (401)')
})
test('throws when response missing fields', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ access_token: 'only_access' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
) as any
await expect(
_internal.exchangeCodeForTokens('code', 'verifier'),
).rejects.toThrow('missing required fields')
})
test('sends correct request body', async () => {
let capturedBody: string | null = null
globalThis.fetch = mock((url: string, opts: any) => {
capturedBody = opts.body
return Promise.resolve(
new Response(
JSON.stringify({
id_token: 'id',
access_token: 'acc',
refresh_token: 'ref',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as any
await _internal.exchangeCodeForTokens('test_code', 'test_verifier', 'http://localhost:1455/auth/callback')
const params = new URLSearchParams(capturedBody!)
expect(params.get('grant_type')).toBe('authorization_code')
expect(params.get('client_id')).toBe(_internal.CLIENT_ID)
expect(params.get('code')).toBe('test_code')
expect(params.get('code_verifier')).toBe('test_verifier')
expect(params.get('redirect_uri')).toBe('http://localhost:1455/auth/callback')
})
})
describe('obtainApiKey', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test('exchanges id_token for API key', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({ access_token: 'sk-api-key-12345' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
) as any
const apiKey = await _internal.obtainApiKey('id_token_value')
expect(apiKey).toBe('sk-api-key-12345')
})
test('throws on non-200 response', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response('Forbidden', { status: 403 }),
),
) as any
await expect(
_internal.obtainApiKey('bad_token'),
).rejects.toThrow('API key exchange failed (403)')
})
test('sends correct token exchange parameters', async () => {
let capturedBody: string | null = null
globalThis.fetch = mock((url: string, opts: any) => {
capturedBody = opts.body
return Promise.resolve(
new Response(
JSON.stringify({ access_token: 'key' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as any
await _internal.obtainApiKey('test_id_token')
const params = new URLSearchParams(capturedBody!)
expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange')
expect(params.get('client_id')).toBe(_internal.CLIENT_ID)
expect(params.get('requested_token')).toBe('openai-api-key')
expect(params.get('subject_token')).toBe('test_id_token')
expect(params.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:id_token')
})
})
})

View File

@@ -0,0 +1,373 @@
/**
* OpenAI Codex (ChatGPT) OAuth flow
*
* Implements the browser-based OAuth login for ChatGPT subscription access.
* Based on the official OpenAI Codex CLI implementation (codex-rs/login/src/server.rs).
*
* Flow:
* 1. Generate PKCE codes + state
* 2. Start local HTTP server on port 1455
* 3. Open browser to OpenAI authorize URL
* 4. Handle callback → exchange code for tokens
* 5. Token exchange: id_token → API key
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'
import { generateCodeVerifier, generateCodeChallenge, generateState } from './crypto.js'
import { openBrowser } from '../../utils/browser.js'
// ─── Constants ───────────────────────────────────────────────────────────────
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize'
const TOKEN_URL = 'https://auth.openai.com/oauth/token'
const DEFAULT_PORT = 1455
const CALLBACK_PATH = '/auth/callback'
const REDIRECT_URI = `http://localhost:${DEFAULT_PORT}${CALLBACK_PATH}`
const SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke'
const JWT_CLAIM_PATH = 'https://api.openai.com/auth'
// ─── Types ───────────────────────────────────────────────────────────────────
export type CodexOAuthResult = {
apiKey: string | null
accessToken: string
refreshToken: string
accountId: string
}
type TokenResponse = {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
type ExchangeResponse = {
access_token: string
}
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string
}
[key: string]: unknown
}
// ─── JWT helpers ─────────────────────────────────────────────────────────────
function decodeJwt(token: string): JwtPayload | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = parts[1] ?? ''
const decoded = Buffer.from(payload, 'base64url').toString('utf-8')
return JSON.parse(decoded) as JwtPayload
} catch {
return null
}
}
function getAccountId(token: string): string | null {
const payload = decodeJwt(token)
const accountId = payload?.[JWT_CLAIM_PATH]?.chatgpt_account_id
return typeof accountId === 'string' && accountId.length > 0 ? accountId : null
}
// ─── URL building ────────────────────────────────────────────────────────────
function buildAuthorizeUrl(
codeChallenge: string,
state: string,
redirectUri: string = REDIRECT_URI,
): string {
const url = new URL(AUTHORIZE_URL)
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', CLIENT_ID)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', SCOPE)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', state)
url.searchParams.set('id_token_add_organizations', 'true')
url.searchParams.set('codex_cli_simplified_flow', 'true')
url.searchParams.set('originator', 'claude-code')
return url.toString()
}
// ─── Token exchange ──────────────────────────────────────────────────────────
async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string = REDIRECT_URI,
): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
}),
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`Token exchange failed (${response.status}): ${text}`)
}
const json = (await response.json()) as TokenResponse
if (!json.access_token || !json.refresh_token) {
throw new Error('Token response missing required fields')
}
return json
}
async function obtainApiKey(idToken: string): Promise<string> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
client_id: CLIENT_ID,
requested_token: 'openai-api-key',
subject_token: idToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
}),
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`API key exchange failed (${response.status}): ${text}`)
}
const json = (await response.json()) as ExchangeResponse
if (!json.access_token) {
throw new Error('API key exchange response missing access_token')
}
return json.access_token
}
// ─── HTML responses ──────────────────────────────────────────────────────────
const SUCCESS_HTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Login Successful</title>
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
.card{text-align:center;padding:2rem;border-radius:12px;background:#16213e;box-shadow:0 4px 24px rgba(0,0,0,.3)}
h1{color:#4ade80;font-size:1.5rem}p{color:#94a3b8;margin-top:.5rem}</style></head>
<body><div class="card"><h1>Authentication Complete</h1><p>You can close this window.</p></div></body></html>`
const ERROR_HTML = (msg: string) => `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Login Error</title>
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
.card{text-align:center;padding:2rem;border-radius:12px;background:#16213e;box-shadow:0 4px 24px rgba(0,0,0,.3)}
h1{color:#f87171;font-size:1.5rem}p{color:#94a3b8;margin-top:.5rem}</style></head>
<body><div class="card"><h1>Authentication Failed</h1><p>${msg}</p></div></body></html>`
// ─── Local callback server ──────────────────────────────────────────────────
function startCallbackServer(
state: string,
port: number,
): Promise<{
waitForCode: () => Promise<string>
close: () => void
}> {
let settlePromise: ((code: string) => void) | ((error: Error) => void) | null = null
const codePromise = new Promise<string>((resolve, reject) => {
settlePromise = resolve
// Also store reject for error cases
;(settlePromise as any).__reject = reject
})
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
try {
const url = new URL(req.url || '', `http://localhost:${port}`)
if (url.pathname !== CALLBACK_PATH) {
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML('Not found'))
return
}
// Check for OAuth error
const error = url.searchParams.get('error')
if (error) {
const desc = url.searchParams.get('error_description') ?? error
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML(desc))
;((settlePromise as any).__reject as (e: Error) => void)?.(new Error(`OAuth error: ${desc}`))
return
}
if (url.searchParams.get('state') !== state) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML('State mismatch'))
;((settlePromise as any).__reject as (e: Error) => void)?.(new Error('State mismatch'))
return
}
const code = url.searchParams.get('code')
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML('Missing authorization code'))
;((settlePromise as any).__reject as (e: Error) => void)?.(new Error('Missing authorization code'))
return
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(SUCCESS_HTML)
;(settlePromise as (code: string) => void)?.(code)
} catch {
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML('Internal error'))
}
})
return new Promise((resolve, reject) => {
server.listen(port, '127.0.0.1', () => {
resolve({
waitForCode: () => codePromise,
close: () => {
server.close()
server.removeAllListeners()
},
})
})
server.on('error', (err: Error & { code?: string }) => {
reject(new Error(`Failed to start callback server on port ${port}: ${err.message}`))
})
})
}
// ─── Manual code parsing ────────────────────────────────────────────────────
/**
* Parse manual user input to extract an authorization code.
* Accepts:
* - A full redirect URL: http://localhost:1455/auth/callback?code=XXX&state=YYY
* - A raw authorization code: XXX
* - code#state format: XXX#YYY
*/
export function parseManualCodeInput(input: string): string | null {
const value = input.trim()
if (!value) return null
// Try as URL
try {
const url = new URL(value)
const code = url.searchParams.get('code')
return code ?? null
} catch {
// Not a URL, continue
}
// Try code#state format — return just the code part
if (value.includes('#')) {
const [code] = value.split('#', 2)
return code ?? null
}
// Return as raw code
return value
}
// ─── Public API ──────────────────────────────────────────────────────────────
export type CodexLoginOptions = {
/** Called with the authorize URL when the flow starts */
onUrl: (url: string) => void
/** Optional: provide a manual authorization code (headless fallback) */
manualCode?: Promise<string>
}
/**
* Perform the complete OpenAI Codex OAuth login flow.
*
* 1. Starts local callback server on port 1455
* 2. Opens browser to OpenAI authorize URL
* 3. Exchanges authorization code for tokens
* 4. Performs token exchange to obtain an API key
* 5. Returns the API key and token information
*/
export async function performOpenAICodexLogin(
options: CodexLoginOptions,
): Promise<CodexOAuthResult> {
const { onUrl, manualCode } = options
// Step 1: Generate PKCE + state
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
// Step 2: Build authorize URL
const authUrl = buildAuthorizeUrl(codeChallenge, state)
onUrl(authUrl)
// Step 3: Start callback server
const server = await startCallbackServer(state, DEFAULT_PORT)
try {
// Step 4: Open browser
await openBrowser(authUrl)
// Step 5: Wait for code (from callback or manual input)
let code: string
if (manualCode) {
// Race between browser callback and manual input
const result = await Promise.race([
server.waitForCode().then(c => ({ source: 'callback' as const, code: c })),
manualCode.then(c => ({ source: 'manual' as const, code: c })),
])
code = result.code
} else {
code = await server.waitForCode()
}
// Step 6: Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier)
// Step 7: Extract account ID
const accountId = getAccountId(tokens.id_token)
if (!accountId) {
throw new Error('Failed to extract ChatGPT account ID from token')
}
// Step 8: Exchange id_token for API key (non-fatal: some accounts lack org, returning null)
let apiKey: string | null = null
try {
apiKey = await obtainApiKey(tokens.id_token)
} catch {
// API key exchange may fail if the ID token lacks organization_id.
// This is expected for some account types — login still succeeds.
}
return {
apiKey,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
accountId,
}
} finally {
server.close()
}
}
// Export helpers for testing
export const _internal = {
CLIENT_ID,
AUTHORIZE_URL,
TOKEN_URL,
REDIRECT_URI,
SCOPE,
buildAuthorizeUrl,
decodeJwt,
getAccountId,
exchangeCodeForTokens,
obtainApiKey,
}

View File

@@ -122,7 +122,6 @@ function buildAgentContent(params: {
'',
instincts
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
.slice(0, 20)
.join('\n'),
'',
].join('\n')

View File

@@ -35,18 +35,15 @@ export function createInstinct(
})
}
const MAX_EVIDENCE_ENTRIES = 10
export function normalizeInstinct(instinct: StoredInstinct): StoredInstinct {
const uniqueEvidence = Array.from(new Set(instinct.evidence.filter(Boolean)))
return {
...instinct,
id: instinct.id || buildInstinctId(instinct.trigger, instinct.action),
confidence: clampConfidence(instinct.confidence),
evidence: uniqueEvidence.slice(-MAX_EVIDENCE_ENTRIES),
evidence: Array.from(new Set(instinct.evidence.filter(Boolean))),
evidenceOutcome: instinct.evidenceOutcome,
observationIds: instinct.observationIds
? Array.from(new Set(instinct.observationIds)).slice(-20)
? Array.from(new Set(instinct.observationIds))
: undefined,
}
}

View File

@@ -12,9 +12,6 @@ import {
import type { LearnedSkillDraft, SkillLearningScope } from './types.js'
export const DUPLICATE_SKILL_OVERLAP_THRESHOLD = 0.8
const MAX_EVIDENCE_LINES_PER_APPEND = 20
const MAX_EVIDENCE_LINES_IN_SKILL = 20
const MAX_SKILL_FILE_BYTES = 50_000
export type SkillGeneratorOptions = {
cwd?: string
@@ -104,41 +101,20 @@ export async function appendInstinctEvidenceToSkill(
const existing = await readFile(target.path, 'utf8').catch(
() => target.content,
)
// Skip if the file already exceeds the size cap
if (Buffer.byteLength(existing, 'utf8') >= MAX_SKILL_FILE_BYTES) {
return target.path
}
const allEvidence = instincts.flatMap(instinct =>
instinct.evidence.map(evidence => `- ${evidence}`),
)
const evidenceLines = allEvidence.slice(0, MAX_EVIDENCE_LINES_PER_APPEND)
if (evidenceLines.length < allEvidence.length) {
evidenceLines.push(
`- [... ${allEvidence.length - evidenceLines.length} more evidence entries omitted]`,
)
}
const now = new Date().toISOString()
const block = [
'',
`## Learned evidence (${now})`,
'',
...evidenceLines,
...instincts.flatMap(instinct =>
instinct.evidence.map(evidence => `- ${evidence}`),
),
'',
].join('\n')
const merged = existing.endsWith('\n')
? existing + block
: `${existing}\n${block}`
// Final guard: truncate if merged exceeds size cap
const finalContent =
Buffer.byteLength(merged, 'utf8') > MAX_SKILL_FILE_BYTES
? merged.slice(0, MAX_SKILL_FILE_BYTES)
: merged
await writeFile(target.path, finalContent, 'utf8')
await writeFile(target.path, merged, 'utf8')
clearSkillIndexCache()
return target.path
}
@@ -215,7 +191,6 @@ function buildSkillContent(params: {
'',
instincts
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
.slice(0, MAX_EVIDENCE_LINES_IN_SKILL)
.join('\n'),
'',
]

View File

@@ -354,7 +354,6 @@ export async function countTokensViaHaikuFallback(
},
startTime: new Date(apiStart),
endTime: new Date(),
...(containsThinking && { thinking: { type: 'enabled', budgetTokens: TOKEN_COUNT_THINKING_BUDGET } }),
})
endTrace(langfuseTrace)

View File

@@ -1,153 +0,0 @@
import { EventEmitter } from 'node:events'
import type { Socket } from 'node:net'
import { describe, expect, test } from 'bun:test'
import { attachNdjsonFramer } from '../ndjsonFramer.js'
type TestSocket = Socket & {
destroyed: boolean
emitData: (chunk: Buffer) => void
}
function createTestSocket(): TestSocket {
const emitter = new EventEmitter() as TestSocket
emitter.destroyed = false
emitter.destroy = ((_error?: Error) => {
emitter.destroyed = true
emitter.emit('close')
return emitter
}) as TestSocket['destroy']
emitter.emitData = (chunk: Buffer) => {
emitter.emit('data', chunk)
}
return emitter
}
describe('attachNdjsonFramer', () => {
test('accepts a complete frame at the configured byte limit', () => {
const socket = createTestSocket()
const messages: unknown[] = []
const errors: Error[] = []
attachNdjsonFramer(
socket,
msg => messages.push(msg),
text => JSON.parse(text) as unknown,
{
maxFrameBytes: Buffer.byteLength('{"a":1}', 'utf8'),
onFrameError: error => errors.push(error),
},
)
socket.emitData(Buffer.from('{"a":1}\n'))
expect(messages).toEqual([{ a: 1 }])
expect(errors).toEqual([])
expect(socket.destroyed).toBe(false)
})
test('destroys a complete frame over the configured byte limit', () => {
const socket = createTestSocket()
const messages: unknown[] = []
const errors: Error[] = []
attachNdjsonFramer(
socket,
msg => messages.push(msg),
text => JSON.parse(text) as unknown,
{
maxFrameBytes: 8,
onFrameError: error => errors.push(error),
},
)
socket.emitData(Buffer.from('{"long":true}\n'))
expect(messages).toEqual([])
expect(errors[0]?.message).toContain('NDJSON frame exceeded')
expect(socket.destroyed).toBe(true)
})
test('destroys oversized no-newline input before a frame can form', () => {
const socket = createTestSocket()
const messages: unknown[] = []
const errors: Error[] = []
attachNdjsonFramer(
socket,
msg => messages.push(msg),
text => JSON.parse(text) as unknown,
{
maxFrameBytes: 8,
onFrameError: error => errors.push(error),
},
)
socket.emitData(Buffer.from('x'.repeat(9)))
expect(messages).toEqual([])
expect(errors[0]?.message).toContain('NDJSON frame exceeded')
expect(socket.destroyed).toBe(true)
})
test('lets callers own oversized-frame shutdown when configured', () => {
const socket = createTestSocket()
const errors: Error[] = []
attachNdjsonFramer(
socket,
() => undefined,
text => JSON.parse(text) as unknown,
{
maxFrameBytes: 8,
onFrameError: error => errors.push(error),
destroyOnFrameError: false,
},
)
socket.emitData(Buffer.from('{"long":true}\n'))
expect(errors[0]?.message).toContain('NDJSON frame exceeded')
expect(socket.destroyed).toBe(false)
})
test('reports malformed non-empty frames without changing default compatibility', () => {
const socket = createTestSocket()
const messages: unknown[] = []
const errors: Error[] = []
attachNdjsonFramer(
socket,
msg => messages.push(msg),
text => JSON.parse(text) as unknown,
{
onInvalidFrame: error => errors.push(error),
},
)
socket.emitData(Buffer.from('{not-json\n'))
expect(messages).toEqual([])
expect(errors).toHaveLength(1)
expect(socket.destroyed).toBe(false)
})
test('destroys malformed frames when configured by the caller', () => {
const socket = createTestSocket()
const errors: Error[] = []
attachNdjsonFramer(
socket,
() => undefined,
text => JSON.parse(text) as unknown,
{
destroyOnInvalidFrame: true,
onInvalidFrame: error => errors.push(error),
},
)
socket.emitData(Buffer.from('{not-json\n'))
expect(errors).toHaveLength(1)
expect(socket.destroyed).toBe(true)
})
})

View File

@@ -1,504 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import type { Message } from 'src/types/message.js'
import { getErrnoCode } from 'src/utils/errors.js'
import {
compactMailboxMessages,
getLastPeerDmSummary,
getInboxPath,
markMessageAsReadByIndex,
markMessageAsReadByIdentity,
markMessagesAsRead,
markMessagesAsReadByPredicate,
MAX_MAILBOX_MESSAGE_TEXT_BYTES,
MAX_MAILBOX_FILE_BYTES,
MAX_MAILBOX_MESSAGES,
MAX_READ_MAILBOX_MESSAGES,
MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES,
readMailbox,
type TeammateMessage,
writeToMailbox,
} from 'src/utils/teammateMailbox.js'
let tempHome = ''
let previousConfigDir: string | undefined
function message(
text: string,
read: boolean,
timestamp = new Date(0).toISOString(),
): TeammateMessage {
return {
from: 'team-lead',
text,
timestamp,
read,
}
}
async function seedMailbox(
agentName: string,
teamName: string,
messages: TeammateMessage[],
): Promise<void> {
const inboxPath = getInboxPath(agentName, teamName)
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, JSON.stringify(messages, null, 2), 'utf-8')
}
async function readRawMailbox(
agentName: string,
teamName: string,
): Promise<TeammateMessage[]> {
const content = await readFile(getInboxPath(agentName, teamName), 'utf-8')
return JSON.parse(content) as TeammateMessage[]
}
describe('compactMailboxMessages', () => {
test('prioritizes unread messages and keeps only recent read history', () => {
const compacted = compactMailboxMessages(
[
message('read-1', true),
message('read-2', true),
message('unread-1', false),
message('read-3', true),
message('unread-2', false),
message('read-4', true),
message('read-5', true),
message('unread-3', false),
],
{ maxMessages: 5, maxReadMessages: 2 },
)
expect(compacted.map(m => m.text)).toEqual([
'unread-1',
'unread-2',
'read-4',
'read-5',
'unread-3',
])
})
test('retains unread protocol messages separately from regular cap', () => {
const protocol = message(
JSON.stringify({ type: 'permission_response', request_id: 'req-1' }),
false,
)
const compacted = compactMailboxMessages(
[
protocol,
...Array.from({ length: 5 }, (_value, index) =>
message(`regular-${index}`, false),
),
],
{
maxMessages: 2,
maxReadMessages: 0,
maxUnreadProtocolMessages: 1,
},
)
expect(compacted.map(m => m.text)).toEqual([
protocol.text,
'regular-3',
'regular-4',
])
})
test('does not prioritize malformed JSON-like unread messages as protocol', () => {
const compacted = compactMailboxMessages(
[
message('{not-json', false),
message('regular-1', false),
message('regular-2', false),
],
{
maxMessages: 1,
maxReadMessages: 0,
maxUnreadProtocolMessages: 10,
},
)
expect(compacted.map(m => m.text)).toEqual(['regular-2'])
})
test('caps unread protocol messages with an independent bound', () => {
const compacted = compactMailboxMessages(
Array.from(
{ length: MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES + 1 },
(_value, index) =>
message(
JSON.stringify({
type: 'permission_response',
request_id: `req-${index}`,
}),
false,
),
),
)
expect(compacted).toHaveLength(MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES)
expect(compacted[0]?.text).toContain('req-1')
})
test('keeps retained mailbox bytes under an explicit budget', () => {
const compacted = compactMailboxMessages(
Array.from({ length: 20 }, (_value, index) =>
message(`msg-${index}-${'x'.repeat(200)}`, false),
),
{
maxMessages: 20,
maxReadMessages: 0,
maxRetainedBytes: 1_000,
},
)
expect(
Buffer.byteLength(JSON.stringify(compacted), 'utf8'),
).toBeLessThanOrEqual(1_000)
expect(compacted.length).toBeLessThan(20)
expect(compacted.at(-1)?.text).toContain('msg-19')
})
test('returns an empty mailbox when even one message exceeds retained budget', () => {
const compacted = compactMailboxMessages([message('too-large', false)], {
maxMessages: 10,
maxReadMessages: 0,
maxRetainedBytes: 1,
})
expect(compacted).toEqual([])
})
test('returns an empty mailbox when all retention lanes are disabled', () => {
const compacted = compactMailboxMessages([message('unread', false)], {
maxMessages: 0,
maxReadMessages: 0,
maxUnreadProtocolMessages: 0,
maxRetainedBytes: 1_000,
})
expect(compacted).toEqual([])
})
})
describe('teammate mailbox retention', () => {
beforeEach(() => {
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
tempHome = mkdtempSync(join(tmpdir(), 'teammate-mailbox-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterEach(async () => {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
await rm(tempHome, { recursive: true, force: true })
tempHome = ''
})
test('writeToMailbox compacts oversized unread inbox files', async () => {
const existing = Array.from(
{ length: MAX_MAILBOX_MESSAGES + 20 },
(_value, index) => message(`old-${index}`, false),
)
await seedMailbox('worker', 'alpha', existing)
await writeToMailbox(
'worker',
{
from: 'team-lead',
text: 'newest',
timestamp: new Date(1).toISOString(),
},
'alpha',
)
const after = await readMailbox('worker', 'alpha')
expect(after).toHaveLength(MAX_MAILBOX_MESSAGES)
expect(after[0]?.text).toBe('old-21')
expect(after.at(-1)?.text).toBe('newest')
})
test('markMessagesAsRead compacts read history after consumption', async () => {
const existing = Array.from(
{ length: MAX_MAILBOX_MESSAGES + 20 },
(_value, index) => message(`msg-${index}`, false),
)
await seedMailbox('worker', 'alpha', existing)
await markMessagesAsRead('worker', 'alpha')
const after = await readRawMailbox('worker', 'alpha')
expect(after).toHaveLength(MAX_READ_MAILBOX_MESSAGES)
expect(after.every(m => m.read)).toBe(true)
expect(after[0]?.text).toBe(
`msg-${MAX_MAILBOX_MESSAGES + 20 - MAX_READ_MAILBOX_MESSAGES}`,
)
})
test('markMessagesAsReadByPredicate leaves structured messages unread', async () => {
await seedMailbox('worker', 'alpha', [
message('plain', false),
message(JSON.stringify({ type: 'permission_request' }), false),
])
await markMessagesAsReadByPredicate(
'worker',
m => !m.text.includes('permission_request'),
'alpha',
)
const after = await readRawMailbox('worker', 'alpha')
expect(after.map(m => m.read)).toEqual([true, false])
})
test('markMessageAsReadByIdentity survives compaction shifting indexes', async () => {
const permissionResponse = message(
JSON.stringify({ type: 'permission_response', request_id: 'req-1' }),
false,
)
await seedMailbox('worker', 'alpha', [
permissionResponse,
...Array.from({ length: MAX_MAILBOX_MESSAGES + 20 }, (_value, index) =>
message(`regular-${index}`, false),
),
])
await writeToMailbox(
'worker',
{
from: 'team-lead',
text: 'newest',
timestamp: new Date(2).toISOString(),
},
'alpha',
)
const marked = await markMessageAsReadByIdentity(
'worker',
'alpha',
permissionResponse,
)
const after = await readRawMailbox('worker', 'alpha')
expect(marked).toBe(true)
expect(after.some(m => m.text === permissionResponse.text && !m.read)).toBe(
false,
)
})
test('markMessageAsReadByIndex also compacts through the compatibility path', async () => {
const existing = Array.from(
{ length: MAX_MAILBOX_MESSAGES + 10 },
(_value, index) => message(`msg-${index}`, false),
)
await seedMailbox('worker', 'alpha', existing)
await markMessageAsReadByIndex('worker', 'alpha', existing.length - 1)
const after = await readRawMailbox('worker', 'alpha')
expect(after).toHaveLength(MAX_MAILBOX_MESSAGES)
expect(after.some(m => m.text === `msg-${existing.length - 1}`)).toBe(false)
expect(after.at(-1)?.text).toBe(`msg-${existing.length - 2}`)
})
test('writeToMailbox rejects oversized message text instead of storing it', async () => {
await expect(
writeToMailbox(
'worker',
{
from: 'team-lead',
text: 'x'.repeat(MAX_MAILBOX_MESSAGE_TEXT_BYTES + 1),
timestamp: new Date(3).toISOString(),
},
'alpha',
),
).rejects.toThrow('Mailbox message text exceeds')
expect(await readRawMailbox('worker', 'alpha')).toEqual([])
})
test('writeToMailbox fails closed when an existing mailbox is corrupt', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, '{not-json', 'utf-8')
await expect(
writeToMailbox(
'worker',
{
from: 'team-lead',
text: 'new',
timestamp: new Date(4).toISOString(),
},
'alpha',
),
).rejects.toThrow()
expect(await readFile(inboxPath, 'utf-8')).toBe('{not-json')
})
test('writeToMailbox rejects when the inbox path is already a directory', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(inboxPath, { recursive: true })
const error = await writeToMailbox(
'worker',
{
from: 'team-lead',
text: 'new',
timestamp: new Date(5).toISOString(),
},
'alpha',
).then(
() => undefined,
err => err,
)
const code = getErrnoCode(error)
expect(code).toBeDefined()
if (code === undefined) {
throw new Error('Expected filesystem errno code')
}
const expectedCodes =
process.platform === 'win32'
? ['EISDIR', 'EPERM', 'EACCES']
: ['EISDIR']
expect(expectedCodes).toContain(code)
expect((await stat(inboxPath)).isDirectory()).toBe(true)
})
test('readMailbox fails closed on corrupt mailbox content', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, '{not-json', 'utf-8')
await expect(readMailbox('worker', 'alpha')).rejects.toThrow()
})
test('readMailbox rejects non-array mailbox files', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, JSON.stringify({ text: 'not an array' }), 'utf-8')
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
'expected message array',
)
})
test('readMailbox rejects malformed stored message shapes', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(
inboxPath,
JSON.stringify([{ from: 'lead', text: 'missing timestamp' }]),
'utf-8',
)
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
'Invalid mailbox message shape',
)
})
test('readMailbox rejects non-object stored messages', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, JSON.stringify(['not an object']), 'utf-8')
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
'expected object',
)
})
test('readMailbox rejects oversized mailbox files before parsing', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, `[${' '.repeat(MAX_MAILBOX_FILE_BYTES)}]`, 'utf-8')
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
'Mailbox file exceeds',
)
})
test('markMessageAsReadByIdentity returns false for missing mailbox files', async () => {
await expect(
markMessageAsReadByIdentity('worker', 'alpha', message('absent', false)),
).resolves.toBe(false)
})
test('markMessageAsReadByIdentity returns false when the expected message moved out', async () => {
await seedMailbox('worker', 'alpha', [message('other', false)])
await expect(
markMessageAsReadByIdentity('worker', 'alpha', message('missing', false)),
).resolves.toBe(false)
expect((await readRawMailbox('worker', 'alpha'))[0]?.read).toBe(false)
})
test('markMessageAsReadByIdentity returns false on corrupt mailbox content', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, '{not-json', 'utf-8')
await expect(
markMessageAsReadByIdentity('worker', 'alpha', message('missing', false)),
).resolves.toBe(false)
})
})
describe('getLastPeerDmSummary', () => {
test('extracts the final peer direct-message summary from assistant tool use', () => {
const messages = [
{ type: 'user', message: { content: 'wake up' } },
{
type: 'assistant',
message: {
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
to: 'worker-1',
message: 'please check the UDS bounds',
summary: 'Checking UDS bounds',
},
},
],
},
},
] as unknown as Message[]
expect(getLastPeerDmSummary(messages)).toBe(
'[to worker-1] Checking UDS bounds',
)
})
test('stops peer direct-message summary search at the wake-up boundary', () => {
const messages = [
{
type: 'assistant',
message: {
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
to: 'worker-1',
message: 'old message',
},
},
],
},
},
{ type: 'user', message: { content: 'new prompt' } },
] as unknown as Message[]
expect(getLastPeerDmSummary(messages)).toBeUndefined()
})
})

View File

@@ -1,767 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
chmod,
mkdir,
mkdtemp,
readdir,
rm,
stat,
symlink,
unlink,
writeFile,
} from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { createConnection, createServer, type Socket } from 'node:net'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import {
drainInbox,
getDefaultUdsSocketPath,
MAX_UDS_INBOX_ENTRIES,
MAX_UDS_INBOX_BYTES,
MAX_UDS_FRAME_BYTES,
MAX_UDS_CLIENTS,
formatUdsAddress,
parseUdsTarget,
sendUdsMessage,
setOnEnqueue,
startUdsMessaging,
stopUdsMessaging,
UDS_AUTH_TIMEOUT_MS,
} from '../udsMessaging.js'
let previousConfigDir: string | undefined
let tempConfigDir = ''
function socketPath(label: string): string {
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}-${label}`
if (process.platform === 'win32') {
return `\\\\.\\pipe\\claude-code-test-${suffix}`
}
return join(tmpdir(), 'claude-code-test', `${suffix}.sock`)
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function waitForEnqueues(
expected: number,
sendMessages: () => Promise<void>,
): Promise<void> {
let count = 0
let resolveDone: (() => void) | undefined
const done = new Promise<void>(resolve => {
resolveDone = resolve
})
setOnEnqueue(() => {
count++
if (count >= expected) resolveDone?.()
})
await sendMessages()
await Promise.race([
done,
sleep(5_000).then(() => {
throw new Error(`Timed out waiting for ${expected} UDS enqueues`)
}),
])
setOnEnqueue(null)
}
beforeEach(async () => {
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
tempConfigDir = await mkdtemp(join(tmpdir(), 'uds-messaging-home-'))
process.env.CLAUDE_CONFIG_DIR = tempConfigDir
})
afterEach(async () => {
setOnEnqueue(null)
drainInbox()
await stopUdsMessaging()
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
if (tempConfigDir) {
await rm(tempConfigDir, { recursive: true, force: true })
tempConfigDir = ''
}
})
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
await new Promise<void>(resolve => {
server.close(() => resolve())
})
}
describe('UDS inbox retention', () => {
test('drainInbox returns each pending socket message once', async () => {
const path = socketPath('drain')
await startUdsMessaging(path, { isExplicit: true })
expect(process.env.CLAUDE_CODE_MESSAGING_TOKEN).toBeUndefined()
await waitForEnqueues(2, async () => {
await sendUdsMessage(path, { type: 'text', data: 'one' })
await sendUdsMessage(path, { type: 'text', data: 'two' })
})
const drained = drainInbox()
expect(drained.map(entry => entry.message.data)).toEqual(['one', 'two'])
expect(drained.every(entry => entry.status === 'processed')).toBe(true)
expect(drainInbox()).toEqual([])
})
test('inbox is capped when messages arrive faster than they are drained', async () => {
const path = socketPath('cap')
await startUdsMessaging(path, { isExplicit: true })
await waitForEnqueues(MAX_UDS_INBOX_ENTRIES, async () => {
for (let i = 0; i < MAX_UDS_INBOX_ENTRIES; i++) {
await sendUdsMessage(path, { type: 'text', data: String(i) })
}
})
await expect(
sendUdsMessage(path, { type: 'text', data: 'overflow' }),
).rejects.toThrow('inbox full')
const drained = drainInbox()
expect(drained).toHaveLength(MAX_UDS_INBOX_ENTRIES)
expect(drained[0]?.message.data).toBe('0')
expect(drained.at(-1)?.message.data).toBe(String(MAX_UDS_INBOX_ENTRIES - 1))
})
test('inbox is capped by retained bytes before entry count', async () => {
const path = socketPath('byte-cap')
await startUdsMessaging(path, { isExplicit: true })
const payload = 'x'.repeat(32 * 1024)
let accepted = 0
for (;;) {
try {
await sendUdsMessage(path, { type: 'text', data: payload })
accepted++
if (accepted > MAX_UDS_INBOX_BYTES / payload.length + 20) {
throw new Error('byte cap was not enforced')
}
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain('inbox full')
break
}
}
const drained = drainInbox()
expect(drained.length).toBe(accepted)
expect(drained.length).toBeLessThan(MAX_UDS_INBOX_ENTRIES)
})
test('ping replies with pong without enqueueing inbox work', async () => {
const path = socketPath('ping')
await startUdsMessaging(path, { isExplicit: true })
await sendUdsMessage(path, { type: 'ping' })
expect(drainInbox()).toEqual([])
})
test('udsClient helpers authenticate through the capability file', async () => {
const path = socketPath('uds-client')
await startUdsMessaging(path, { isExplicit: true })
const { isPeerAlive, sendToUdsSocket } = await import('../udsClient.js')
expect(await isPeerAlive(path)).toBe(true)
await waitForEnqueues(1, async () => {
await sendToUdsSocket(path, 'hello from client')
})
const drained = drainInbox()
expect(drained).toHaveLength(1)
expect(drained[0]?.message.data).toBe('hello from client')
expect(drained[0]?.message.meta).toBeUndefined()
})
test('udsClient peer probe fails closed on oversized pong frames', async () => {
const path = socketPath('uds-client-oversized-pong')
if (process.platform !== 'win32') {
await mkdir(dirname(path), { recursive: true })
}
const receiver = createServer(socket => {
socket.on('data', () => {
socket.write('x'.repeat(MAX_UDS_FRAME_BYTES + 1))
})
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
try {
const { isPeerAlive } = await import('../udsClient.js')
expect(await isPeerAlive(path, 3_000, 'test-token')).toBe(false)
} finally {
await closeServer(receiver)
if (process.platform !== 'win32') {
await unlink(path).catch(() => undefined)
}
}
})
test('udsClient send fails closed when no capability token exists', async () => {
const path = socketPath('uds-client-no-token')
const { sendToUdsSocket } = await import('../udsClient.js')
await expect(sendToUdsSocket(path, 'hello')).rejects.toThrow(
'No auth token found',
)
})
test('udsClient send reports connection failures without leaking token state', async () => {
const path = socketPath('uds-client-connect-error')
const capabilityDir = join(tempConfigDir, 'messaging-capabilities')
const capabilityName = `${createHash('sha256').update(path).digest('hex')}.json`
await mkdir(capabilityDir, { recursive: true, mode: 0o700 })
await writeFile(
join(capabilityDir, capabilityName),
JSON.stringify({ socketPath: path, authToken: 'test-token' }),
'utf-8',
)
const { sendToUdsSocket, UdsPeerConnectionError } = await import(
'../udsClient.js'
)
const error = await sendToUdsSocket(path, 'hello').then(
() => undefined,
err => err,
)
expect(error).toBeInstanceOf(UdsPeerConnectionError)
if (!(error instanceof UdsPeerConnectionError)) {
throw new Error('Expected UDS peer connection error')
}
expect(error.socketPath).toBe(path)
expect(error.message).not.toContain('test-token')
})
test('udsClient send reports response timeouts as peer connection errors', async () => {
const path = socketPath('uds-client-timeout')
const capabilityDir = join(tempConfigDir, 'messaging-capabilities')
const capabilityName = `${createHash('sha256').update(path).digest('hex')}.json`
await mkdir(capabilityDir, { recursive: true, mode: 0o700 })
await writeFile(
join(capabilityDir, capabilityName),
JSON.stringify({ socketPath: path, authToken: 'test-token' }),
'utf-8',
)
if (process.platform !== 'win32') {
await mkdir(dirname(path), { recursive: true })
}
const sockets = new Set<Socket>()
const receiver = createServer(socket => {
sockets.add(socket)
socket.on('close', () => {
sockets.delete(socket)
})
socket.on('data', () => undefined)
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
try {
const { sendToUdsSocket, UdsPeerConnectionError } = await import(
'../udsClient.js'
)
const error = await sendToUdsSocket(path, 'hello', 200).then(
() => undefined,
err => err,
)
expect(error).toBeInstanceOf(UdsPeerConnectionError)
if (!(error instanceof UdsPeerConnectionError)) {
throw new Error('Expected UDS peer connection timeout error')
}
expect(error.socketPath).toBe(path)
expect(error.cause).toBeInstanceOf(Error)
if (!(error.cause instanceof Error)) {
throw new Error('Expected timeout cause')
}
expect(error.cause.message).toBe('Connection timed out')
expect(error.message).not.toContain('test-token')
} finally {
for (const socket of sockets) {
socket.destroy()
}
await closeServer(receiver)
if (process.platform !== 'win32') {
await unlink(path).catch(() => undefined)
}
}
})
test('connectToPeer reports connection failures as peer connection errors', async () => {
const path = socketPath('uds-connect-error')
const { connectToPeer, UdsPeerConnectionError } = await import(
'../udsClient.js'
)
const error = await connectToPeer(path, () => {
throw new Error('Unexpected post-connect socket error')
}).then(
() => undefined,
err => err,
)
expect(error).toBeInstanceOf(UdsPeerConnectionError)
if (!(error instanceof UdsPeerConnectionError)) {
throw new Error('Expected UDS peer connection error')
}
expect(error.socketPath).toBe(path)
})
test('connectToPeer leaves connected socket lifecycle to the caller', async () => {
const path = socketPath('uds-connect-lifecycle')
if (process.platform !== 'win32') {
await mkdir(dirname(path), { recursive: true })
}
const sockets = new Set<Socket>()
const receiver = createServer(socket => {
sockets.add(socket)
socket.on('close', () => {
sockets.delete(socket)
})
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
let client: Socket | undefined
const socketErrors: Error[] = []
try {
const { connectToPeer } = await import('../udsClient.js')
client = await connectToPeer(
path,
error => {
socketErrors.push(error)
},
1000,
)
await new Promise(resolve => setTimeout(resolve, 100))
expect(client.destroyed).toBe(false)
expect(client.listenerCount('error')).toBe(1)
const socketError = new Error('post-connect failure')
client.emit('error', socketError)
expect(socketErrors).toEqual([socketError])
} finally {
client?.destroy()
for (const socket of sockets) {
socket.destroy()
}
await closeServer(receiver)
if (process.platform !== 'win32') {
await unlink(path).catch(() => undefined)
}
}
})
test('sendUdsMessage fails closed before connecting without an auth token', async () => {
await expect(
sendUdsMessage(socketPath('no-auth-token'), { type: 'text', data: 'x' }),
).rejects.toThrow('without auth token')
})
test('drained entries never expose the UDS auth token', async () => {
const path = socketPath('strip-token')
await startUdsMessaging(path, { isExplicit: true })
await waitForEnqueues(1, async () => {
await sendUdsMessage(path, {
type: 'notification',
meta: { keep: 'visible' },
})
})
const drained = drainInbox()
expect(drained).toHaveLength(1)
expect(drained[0]?.message.meta).toEqual({ keep: 'visible' })
expect(drained[0]?.message.meta).not.toHaveProperty('authToken')
})
test('rejects unauthenticated socket messages', async () => {
const path = socketPath('auth')
await startUdsMessaging(path, { isExplicit: true })
const response = await new Promise<string>((resolve, reject) => {
let responseText = ''
const conn = createConnection(path, () => {
conn.write(`${JSON.stringify({ type: 'text', data: 'bad' })}\n`)
})
conn.setTimeout(5_000, () => {
conn.destroy()
reject(new Error('Timed out waiting for auth rejection'))
})
conn.on('data', chunk => {
const text = chunk.toString('utf-8')
if (text.includes('\n')) {
responseText = text
}
})
conn.on('close', () => resolve(responseText))
conn.on('error', reject)
})
expect(JSON.parse(response).type).toBe('error')
expect(drainInbox()).toEqual([])
})
test('disconnects malformed JSON clients without enqueueing inbox work', async () => {
const path = socketPath('malformed-client')
await startUdsMessaging(path, { isExplicit: true })
const response = await new Promise<string>((resolve, reject) => {
let responseText = ''
const conn = createConnection(path, () => {
conn.write('{not-json\n')
})
conn.setTimeout(5_000, () => {
conn.destroy()
reject(new Error('Timed out waiting for malformed frame close'))
})
conn.on('data', chunk => {
responseText += chunk.toString('utf-8')
})
conn.on('close', () => resolve(responseText))
conn.on('error', reject)
})
const parsed = JSON.parse(response)
expect(parsed.type).toBe('error')
expect(parsed.data).toBe('invalid frame')
expect(drainInbox()).toEqual([])
})
test('disconnects idle unauthenticated clients', async () => {
const path = socketPath('idle-client')
await startUdsMessaging(path, { isExplicit: true })
const response = await new Promise<string>((resolve, reject) => {
let responseText = ''
const conn = createConnection(path)
conn.setTimeout(UDS_AUTH_TIMEOUT_MS + 2_000, () => {
conn.destroy()
reject(new Error('Timed out waiting for auth timeout close'))
})
conn.on('data', chunk => {
responseText += chunk.toString('utf-8')
})
conn.on('close', () => resolve(responseText))
conn.on('error', reject)
})
const parsed = JSON.parse(response)
expect(parsed.type).toBe('error')
expect(parsed.data).toBe('authentication timeout')
expect(drainInbox()).toEqual([])
})
test('destroys oversized frames before enqueueing inbox work', async () => {
const path = socketPath('oversized')
await startUdsMessaging(path, { isExplicit: true })
await new Promise<void>((resolve, reject) => {
const conn = createConnection(path, () => {
conn.write('x'.repeat(MAX_UDS_FRAME_BYTES + 1))
})
conn.setTimeout(5_000, () => {
conn.destroy()
reject(new Error('Timed out waiting for oversized frame close'))
})
conn.on('close', () => resolve())
conn.on('error', () => resolve())
})
expect(drainInbox()).toEqual([])
})
test('default socket path is regenerated after stop', async () => {
const firstPath = getDefaultUdsSocketPath()
await startUdsMessaging(firstPath)
await stopUdsMessaging()
expect(getDefaultUdsSocketPath()).not.toBe(firstPath)
})
test('rejects oversized receiver responses before retaining them', async () => {
const path = socketPath('oversized-response')
if (process.platform !== 'win32') {
await mkdir(dirname(path), { recursive: true })
}
const receiver = createServer(socket => {
socket.on('data', () => {
socket.write('x'.repeat(MAX_UDS_FRAME_BYTES + 1))
})
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
try {
await expect(
sendUdsMessage(
path,
{ type: 'text', data: 'hello' },
{ authToken: 'test-token' },
),
).rejects.toThrow('UDS response frame exceeded size limit')
} finally {
await closeServer(receiver)
if (process.platform !== 'win32') {
await unlink(path).catch(() => undefined)
}
}
})
test('rejects closed receiver responses without waiting for timeout', async () => {
const path = socketPath('closed-response')
if (process.platform !== 'win32') {
await mkdir(dirname(path), { recursive: true })
}
const receiver = createServer(socket => {
socket.end()
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
try {
await expect(
sendUdsMessage(
path,
{ type: 'text', data: 'hello' },
{ authToken: 'test-token' },
),
).rejects.toThrow('before response')
} finally {
await closeServer(receiver)
if (process.platform !== 'win32') {
await unlink(path).catch(() => undefined)
}
}
})
test('rejects malformed receiver responses without waiting for timeout', async () => {
const path = socketPath('malformed-response')
if (process.platform !== 'win32') {
await mkdir(dirname(path), { recursive: true })
}
const receiver = createServer(socket => {
socket.on('data', () => {
socket.write('{not-json\n')
})
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
try {
await expect(
sendUdsMessage(
path,
{ type: 'text', data: 'hello' },
{ authToken: 'test-token' },
),
).rejects.toThrow('Invalid UDS response frame')
} finally {
await closeServer(receiver)
if (process.platform !== 'win32') {
await unlink(path).catch(() => undefined)
}
}
})
test('rejects inline auth token UDS targets instead of parsing them', async () => {
const path = socketPath('inline-token')
expect(formatUdsAddress(path)).toBe(`uds:${path}`)
const targetWithToken = `${path}#token=secret`
expect(() => parseUdsTarget(targetWithToken)).toThrow('inline auth token')
try {
parseUdsTarget(targetWithToken)
} catch (error) {
expect((error as Error).message).not.toContain('secret')
}
const { sendToUdsSocket } = await import('../udsClient.js')
await expect(sendToUdsSocket(targetWithToken, 'hello')).rejects.toThrow(
'inline auth token',
)
})
test('fails closed and cleans temp files when capability target is occupied', async () => {
const path = socketPath('capability-target-dir')
const capabilityDir = join(tempConfigDir, 'messaging-capabilities')
const capabilityName = `${createHash('sha256').update(path).digest('hex')}.json`
await mkdir(join(capabilityDir, capabilityName), {
recursive: true,
mode: 0o700,
})
await expect(
startUdsMessaging(path, { isExplicit: true }),
).rejects.toThrow()
expect(process.env.CLAUDE_CODE_MESSAGING_SOCKET).toBeUndefined()
expect(await readdir(capabilityDir)).toEqual([capabilityName])
})
if (process.platform !== 'win32') {
test('creates the listening socket with owner-only permissions', async () => {
const path = socketPath('socket-mode')
await startUdsMessaging(path, { isExplicit: true })
const mode = (await stat(path)).mode & 0o777
expect(mode).toBe(0o600)
})
test('fails closed when the capability directory is not private', async () => {
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const tempHome = join(
tmpdir(),
`uds-capability-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
process.env.CLAUDE_CONFIG_DIR = tempHome
const capabilityDir = join(tempHome, 'messaging-capabilities')
await mkdir(capabilityDir, { recursive: true, mode: 0o755 })
await chmod(capabilityDir, 0o755)
try {
const path = socketPath('broad-capdir')
await expect(
startUdsMessaging(path, { isExplicit: true }),
).rejects.toThrow('permissions are too broad')
await expect(stat(path)).rejects.toThrow()
} finally {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
await rm(tempHome, { recursive: true, force: true })
}
})
test('fails closed when the capability directory is a symlink', async () => {
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const tempHome = join(
tmpdir(),
`uds-capability-link-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
const target = join(tempHome, 'target')
process.env.CLAUDE_CONFIG_DIR = tempHome
await mkdir(target, { recursive: true, mode: 0o700 })
await symlink(target, join(tempHome, 'messaging-capabilities'), 'dir')
try {
await expect(
startUdsMessaging(socketPath('symlink-capdir'), { isExplicit: true }),
).rejects.toThrow('not a private directory')
} finally {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
await rm(tempHome, { recursive: true, force: true })
}
})
test('fails closed when an explicit socket parent is not private', async () => {
const parent = join(
tmpdir(),
`uds-socket-parent-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
await mkdir(parent, { recursive: true, mode: 0o755 })
await chmod(parent, 0o755)
try {
await expect(
startUdsMessaging(join(parent, 'messaging.sock'), {
isExplicit: true,
}),
).rejects.toThrow('socket parent permissions are too broad')
} finally {
await rm(parent, { recursive: true, force: true })
}
})
test('fails closed when an explicit socket parent is a file', async () => {
const parentFile = join(
tmpdir(),
`uds-socket-parent-file-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
await writeFile(parentFile, 'not a directory', 'utf-8')
try {
await expect(
startUdsMessaging(join(parentFile, 'messaging.sock'), {
isExplicit: true,
}),
).rejects.toThrow('socket parent is not a directory')
} finally {
await rm(parentFile, { force: true })
}
})
test('stop tolerates an already removed socket path', async () => {
const path = socketPath('already-removed')
await startUdsMessaging(path, { isExplicit: true })
await unlink(path)
await stopUdsMessaging()
expect(process.env.CLAUDE_CODE_MESSAGING_SOCKET).toBeUndefined()
})
test('rejects clients over the configured connection cap', async () => {
const path = socketPath('client-cap')
await startUdsMessaging(path, { isExplicit: true })
const sockets: ReturnType<typeof createConnection>[] = []
try {
for (let i = 0; i < MAX_UDS_CLIENTS; i++) {
const socket = await new Promise<ReturnType<typeof createConnection>>(
(resolve, reject) => {
const conn = createConnection(path, () => resolve(conn))
conn.on('error', reject)
},
)
sockets.push(socket)
}
await new Promise<void>((resolve, reject) => {
const extra = createConnection(path)
extra.on('close', () => resolve())
extra.on('error', reject)
extra.setTimeout(5_000, () => {
extra.destroy()
reject(new Error('Timed out waiting for client cap close'))
})
})
} finally {
for (const socket of sockets) {
socket.destroy()
}
}
})
}
})

View File

@@ -1,218 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { EventEmitter } from 'node:events'
import type { Socket } from 'node:net'
import { attachUdsResponseReader } from '../udsResponseReader.js'
class FakeSocket extends EventEmitter {
destroyed = false
ended = false
destroy(): this {
this.destroyed = true
this.emit('close', true)
return this
}
end(): this {
this.ended = true
this.emit('close', false)
return this
}
emitData(chunk: Buffer): void {
this.emit('data', chunk)
}
}
function asSocket(socket: FakeSocket): Socket {
return socket as unknown as Socket
}
describe('attachUdsResponseReader', () => {
test('tracks byte limits across split multibyte response chunks', () => {
const socket = new FakeSocket()
let settled = false
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settled = true
settledError = error
},
})
const multibyte = String.fromCodePoint(0x20ac)
const frame = Buffer.from(
JSON.stringify({ type: 'response', data: `ok ${multibyte}` }) + '\n',
'utf8',
)
const multibyteStart = frame.indexOf(Buffer.from(multibyte, 'utf8')[0])
socket.emitData(frame.subarray(0, multibyteStart + 1))
expect(settled).toBe(false)
socket.emitData(frame.subarray(multibyteStart + 1))
expect(settled).toBe(true)
expect(settledError).toBeUndefined()
expect(socket.ended).toBe(true)
})
test('rejects malformed response frames immediately', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
})
socket.emitData(Buffer.from('{bad-json}\n'))
expect(settledError?.message).toBe('Invalid UDS response frame')
expect(socket.destroyed).toBe(true)
})
test('skips blank frames before a valid response', () => {
const socket = new FakeSocket()
let settled = false
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settled = true
settledError = error
},
})
socket.emitData(Buffer.from('\n \n'))
expect(settled).toBe(false)
socket.emitData(Buffer.from(`${JSON.stringify({ type: 'response' })}\n`))
expect(settled).toBe(true)
expect(settledError).toBeUndefined()
expect(socket.ended).toBe(true)
})
test('continues scanning when blank and valid frames share one chunk', () => {
const socket = new FakeSocket()
let settled = false
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settled = true
settledError = error
},
})
socket.emitData(
Buffer.from(`\n${JSON.stringify({ type: 'response' })}\n`),
)
expect(settled).toBe(true)
expect(settledError).toBeUndefined()
expect(socket.ended).toBe(true)
})
test('rejects receiver error frames', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
})
socket.emitData(
Buffer.from(`${JSON.stringify({ type: 'error', data: 'denied' })}\n`),
)
expect(settledError?.message).toBe('denied')
expect(socket.destroyed).toBe(true)
})
test('ignores unrelated receiver frames until a terminal response arrives', () => {
const socket = new FakeSocket()
let settled = false
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settled = true
settledError = error
},
})
socket.emitData(
Buffer.from(
`${JSON.stringify({ type: 'notification', data: 'queued' })}\n`,
),
)
expect(settled).toBe(false)
socket.emitData(Buffer.from(`${JSON.stringify({ type: 'response' })}\n`))
expect(settled).toBe(true)
expect(settledError).toBeUndefined()
})
test('uses custom socket error formatting', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
formatSocketError: error =>
new Error(`wrapped:${(error as Error).message}`),
})
socket.emit('error', new Error('connect failed'))
expect(settledError?.message).toBe('wrapped:connect failed')
expect(socket.destroyed).toBe(true)
})
test('rejects socket end before response', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
})
socket.emit('end')
expect(settledError?.message).toBe('UDS socket ended before response')
expect(socket.destroyed).toBe(true)
})
test('rejects clean socket close before response', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
})
socket.emit('close', false)
expect(settledError?.message).toBe('UDS socket closed before response')
expect(socket.destroyed).toBe(true)
})
})

View File

@@ -23,6 +23,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_CODEX',
// Endpoint config (base URLs, project/resource identifiers)
'ANTHROPIC_BASE_URL',
'ANTHROPIC_BEDROCK_BASE_URL',
@@ -31,6 +32,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'ANTHROPIC_FOUNDRY_RESOURCE',
'ANTHROPIC_VERTEX_PROJECT_ID',
'GEMINI_BASE_URL',
'CODEX_BASE_URL',
// Region routing (per-model VERTEX_REGION_CLAUDE_* handled by prefix below)
'CLOUD_ML_REGION',
// Auth
@@ -43,6 +45,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
'GEMINI_API_KEY',
'CODEX_API_KEY',
// Model defaults — often set to provider-specific ID formats
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
@@ -92,6 +95,17 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION',
'GEMINI_DEFAULT_SONNET_MODEL_NAME',
'GEMINI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
// Codex provider specific
'CODEX_BASE_URL',
'CODEX_API_KEY',
'CODEX_MODEL',
'CODEX_DEFAULT_HAIKU_MODEL',
'CODEX_DEFAULT_SONNET_MODEL',
'CODEX_DEFAULT_OPUS_MODEL',
'CODEX_IMGBB_API_KEY',
'CODEX_LOGIN_METHOD',
'CODEX_ACCESS_TOKEN',
'CODEX_REFRESH_TOKEN',
])
const PROVIDER_MANAGED_ENV_PREFIXES = [
@@ -201,6 +215,7 @@ export const SAFE_ENV_VARS = new Set([
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_CODEX',
'GEMINI_MODEL',
'GEMINI_SMALL_FAST_MODEL',
'GEMINI_DEFAULT_HAIKU_MODEL',
@@ -215,6 +230,11 @@ export const SAFE_ENV_VARS = new Set([
'GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION',
'GEMINI_DEFAULT_SONNET_MODEL_NAME',
'GEMINI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
// Codex provider specific
'CODEX_DEFAULT_HAIKU_MODEL',
'CODEX_DEFAULT_SONNET_MODEL',
'CODEX_DEFAULT_OPUS_MODEL',
'CODEX_IMGBB_API_KEY',
'DISABLE_AUTOUPDATER',
'DISABLE_BUG_COMMAND',
'DISABLE_COST_WARNINGS',

View File

@@ -13,6 +13,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = {
foundry: 'claude-3-7-sonnet',
openai: 'claude-3-7-sonnet-20250219',
gemini: 'claude-3-7-sonnet-20250219',
codex: 'gpt-5.4-mini',
grok: 'claude-3-7-sonnet-20250219',
} as const satisfies ModelConfig
@@ -23,6 +24,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = {
foundry: 'claude-3-5-sonnet',
openai: 'claude-3-5-sonnet-20241022',
gemini: 'claude-3-5-sonnet-20241022',
codex: 'gpt-5.4-mini',
grok: 'claude-3-5-sonnet-20241022',
} as const satisfies ModelConfig
@@ -33,6 +35,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = {
foundry: 'claude-3-5-haiku',
openai: 'claude-3-5-haiku-20241022',
gemini: 'claude-3-5-haiku-20241022',
codex: 'gpt-5.4-mini',
grok: 'claude-3-5-haiku-20241022',
} as const satisfies ModelConfig
@@ -43,6 +46,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = {
foundry: 'claude-haiku-4-5',
openai: 'claude-haiku-4-5-20251001',
gemini: 'claude-haiku-4-5-20251001',
codex: 'gpt-5.4-mini',
grok: 'claude-haiku-4-5-20251001',
} as const satisfies ModelConfig
@@ -53,6 +57,7 @@ export const CLAUDE_SONNET_4_CONFIG = {
foundry: 'claude-sonnet-4',
openai: 'claude-sonnet-4-20250514',
gemini: 'claude-sonnet-4-20250514',
codex: 'gpt-5.4-mini',
grok: 'claude-sonnet-4-20250514',
} as const satisfies ModelConfig
@@ -63,6 +68,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = {
foundry: 'claude-sonnet-4-5',
openai: 'claude-sonnet-4-5-20250929',
gemini: 'claude-sonnet-4-5-20250929',
codex: 'gpt-5.4-mini',
grok: 'claude-sonnet-4-5-20250929',
} as const satisfies ModelConfig
@@ -73,6 +79,7 @@ export const CLAUDE_OPUS_4_CONFIG = {
foundry: 'claude-opus-4',
openai: 'claude-opus-4-20250514',
gemini: 'claude-opus-4-20250514',
codex: 'gpt-5.4',
grok: 'claude-opus-4-20250514',
} as const satisfies ModelConfig
@@ -83,6 +90,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = {
foundry: 'claude-opus-4-1',
openai: 'claude-opus-4-1-20250805',
gemini: 'claude-opus-4-1-20250805',
codex: 'gpt-5.4',
grok: 'claude-opus-4-1-20250805',
} as const satisfies ModelConfig
@@ -93,6 +101,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = {
foundry: 'claude-opus-4-5',
openai: 'claude-opus-4-5-20251101',
gemini: 'claude-opus-4-5-20251101',
codex: 'gpt-5.4',
grok: 'claude-opus-4-5-20251101',
} as const satisfies ModelConfig
@@ -103,6 +112,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = {
foundry: 'claude-opus-4-6',
openai: 'claude-opus-4-6',
gemini: 'claude-opus-4-6',
codex: 'gpt-5.4',
grok: 'claude-opus-4-6',
} as const satisfies ModelConfig
@@ -113,6 +123,7 @@ export const CLAUDE_OPUS_4_7_CONFIG = {
foundry: 'claude-opus-4-7',
openai: 'claude-opus-4-7',
gemini: 'claude-opus-4-7',
codex: 'gpt-5.5',
grok: 'claude-opus-4-7',
} as const satisfies ModelConfig
@@ -123,6 +134,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = {
foundry: 'claude-sonnet-4-6',
openai: 'claude-sonnet-4-6',
gemini: 'claude-sonnet-4-6',
codex: 'gpt-5.4-mini',
grok: 'claude-sonnet-4-6',
} as const satisfies ModelConfig

View File

@@ -83,7 +83,9 @@ function getCustomSonnetOption(): ModelOption | undefined {
? process.env.OPENAI_DEFAULT_SONNET_MODEL
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_SONNET_MODEL
: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
: provider === 'codex'
? process.env.CODEX_DEFAULT_SONNET_MODEL
: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
// When a 3P user has a custom sonnet model string, show it directly
if (is3P && customSonnetModel) {
const is1m = has1mContext(customSonnetModel)
@@ -93,13 +95,17 @@ function getCustomSonnetOption(): ModelOption | undefined {
? process.env.OPENAI_DEFAULT_SONNET_MODEL_NAME
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_SONNET_MODEL_NAME
: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME
: provider === 'codex'
? process.env.CODEX_DEFAULT_SONNET_MODEL_NAME
: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME
const descEnv =
provider === 'openai'
? process.env.OPENAI_DEFAULT_SONNET_MODEL_DESCRIPTION
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION
: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION
: provider === 'codex'
? process.env.CODEX_DEFAULT_SONNET_MODEL_DESCRIPTION
: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION
return {
value: 'sonnet',
label: nameEnv ?? customSonnetModel,
@@ -132,7 +138,9 @@ function getCustomOpusOption(): ModelOption | undefined {
? process.env.OPENAI_DEFAULT_OPUS_MODEL
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_OPUS_MODEL
: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
: provider === 'codex'
? process.env.CODEX_DEFAULT_OPUS_MODEL
: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
// When a 3P user has a custom opus model string, show it directly
if (is3P && customOpusModel) {
const is1m = has1mContext(customOpusModel)
@@ -142,13 +150,17 @@ function getCustomOpusOption(): ModelOption | undefined {
? process.env.OPENAI_DEFAULT_OPUS_MODEL_NAME
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_OPUS_MODEL_NAME
: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME
: provider === 'codex'
? process.env.CODEX_DEFAULT_OPUS_MODEL_NAME
: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME
const descEnv =
provider === 'openai'
? process.env.OPENAI_DEFAULT_OPUS_MODEL_DESCRIPTION
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_OPUS_MODEL_DESCRIPTION
: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION
: provider === 'codex'
? process.env.CODEX_DEFAULT_OPUS_MODEL_DESCRIPTION
: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION
return {
value: 'opus',
label: nameEnv ?? customOpusModel,
@@ -232,7 +244,9 @@ function getCustomHaikuOption(): ModelOption | undefined {
? process.env.OPENAI_DEFAULT_HAIKU_MODEL
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_HAIKU_MODEL
: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
: provider === 'codex'
? process.env.CODEX_DEFAULT_HAIKU_MODEL
: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
// When a 3P user has a custom haiku model string, show it directly
if (is3P && customHaikuModel) {
// Use appropriate NAME/DESCRIPTION env vars based on provider
@@ -241,13 +255,17 @@ function getCustomHaikuOption(): ModelOption | undefined {
? process.env.OPENAI_DEFAULT_HAIKU_MODEL_NAME
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_HAIKU_MODEL_NAME
: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME
: provider === 'codex'
? process.env.CODEX_DEFAULT_HAIKU_MODEL_NAME
: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME
const descEnv =
provider === 'openai'
? process.env.OPENAI_DEFAULT_HAIKU_MODEL_DESCRIPTION
: provider === 'gemini'
? process.env.GEMINI_DEFAULT_HAIKU_MODEL_DESCRIPTION
: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION
: provider === 'codex'
? process.env.CODEX_DEFAULT_HAIKU_MODEL_DESCRIPTION
: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION
return {
value: 'haiku',
label: nameEnv ?? customHaikuModel,

View File

@@ -8,12 +8,14 @@ export type APIProvider =
| 'vertex'
| 'foundry'
| 'openai'
| 'codex'
| 'gemini'
| 'grok'
export function getAPIProvider(): APIProvider {
const modelType = getInitialSettings().modelType
if (modelType === 'openai') return 'openai'
if (modelType === 'codex') return 'codex'
if (modelType === 'gemini') return 'gemini'
if (modelType === 'grok') return 'grok'
@@ -22,6 +24,7 @@ export function getAPIProvider(): APIProvider {
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) return 'foundry'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) return 'openai'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CODEX)) return 'codex'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) return 'gemini'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GROK)) return 'grok'

View File

@@ -7,18 +7,9 @@
*/
import type { Socket } from 'net'
export type NdjsonFramerOptions = {
maxFrameBytes?: number
onFrameError?: (error: Error) => void
destroyOnFrameError?: boolean
onInvalidFrame?: (error: Error) => void
destroyOnInvalidFrame?: boolean
}
/**
* Attach an NDJSON framer to a socket. Calls `onMessage` for each
* complete JSON line received. Malformed lines are skipped by default;
* callers may opt into error callbacks or socket destruction.
* complete JSON line received. Malformed lines are silently skipped.
*
* @param parse - Optional custom JSON parser (defaults to JSON.parse).
* Useful when the caller uses a wrapped parser like jsonParse
@@ -28,73 +19,21 @@ export function attachNdjsonFramer<T = unknown>(
socket: Socket,
onMessage: (msg: T) => void,
parse: (text: string) => T = text => JSON.parse(text) as T,
options: NdjsonFramerOptions = {},
): void {
let buffer = ''
let bufferBytes = 0
const maxFrameBytes = options.maxFrameBytes ?? Number.POSITIVE_INFINITY
const rejectOversizedFrame = (bytes: number): void => {
const error = new Error(
`NDJSON frame exceeded ${maxFrameBytes} bytes (${bytes})`,
)
options.onFrameError?.(error)
if (options.destroyOnFrameError ?? true) {
socket.destroy(error)
}
}
const rejectInvalidFrame = (error: unknown): void => {
const frameError =
error instanceof Error ? error : new Error('Invalid NDJSON frame')
options.onInvalidFrame?.(frameError)
if (options.destroyOnInvalidFrame ?? false) {
socket.destroy(frameError)
}
}
const emitLine = (line: string): void => {
if (!line.trim()) return
try {
onMessage(parse(line))
} catch (error) {
rejectInvalidFrame(error)
}
}
socket.on('data', (chunk: Buffer) => {
let start = 0
for (let index = 0; index < chunk.length; index++) {
if (chunk[index] !== 0x0a) continue
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
const segmentBytes = index - start
if (
Number.isFinite(maxFrameBytes) &&
bufferBytes + segmentBytes > maxFrameBytes
) {
rejectOversizedFrame(bufferBytes + segmentBytes)
return
for (const line of lines) {
if (!line.trim()) continue
try {
onMessage(parse(line))
} catch {
// Malformed JSON — skip
}
buffer += chunk.subarray(start, index).toString('utf8')
emitLine(buffer)
buffer = ''
bufferBytes = 0
start = index + 1
}
const tailBytes = chunk.length - start
if (
Number.isFinite(maxFrameBytes) &&
bufferBytes + tailBytes > maxFrameBytes
) {
rejectOversizedFrame(bufferBytes + tailBytes)
return
}
if (tailBytes > 0) {
buffer += chunk.subarray(start).toString('utf8')
bufferBytes += tailBytes
}
})
}

View File

@@ -369,11 +369,11 @@ export const SettingsSchema = lazySchema(() =>
.optional()
.describe('Tool usage permissions configuration'),
modelType: z
.enum(['anthropic', 'openai', 'gemini', 'grok'])
.enum(['anthropic', 'openai', 'gemini', 'grok', 'codex'])
.optional()
.describe(
'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, and "grok" uses the xAI Grok API (OpenAI-compatible). ' +
'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP.',
'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, "grok" uses the xAI Grok API (OpenAI-compatible), and "codex" uses the OpenAI Responses API via ChatGPT subscription or API key. ' +
'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP. When set to "codex", configure CODEX_API_KEY and optional CODEX_BASE_URL.',
),
model: z
.string()

View File

@@ -294,12 +294,6 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
startTime: new Date(start),
endTime: new Date(),
...(tools && { tools: convertToolsToLangfuse(tools as unknown[]) }),
...(thinkingConfig && thinkingConfig.type !== 'disabled' && {
thinking: {
type: thinkingConfig.type,
...(thinkingConfig.type === 'enabled' && { budgetTokens: thinkingConfig.budget_tokens }),
},
}),
})
endTrace(langfuseTrace)

View File

@@ -342,6 +342,7 @@ export function buildAPIProviderProperties(): Property[] {
gemini: 'Gemini API',
grok: 'Grok API',
openai: 'OpenAI API',
codex: 'Codex API',
}[apiProvider]
properties.push({
label: 'API provider',

View File

@@ -97,7 +97,7 @@ import {
getLastPeerDmSummary,
isPermissionResponse,
isShutdownRequest,
markMessageAsReadByIdentity,
markMessageAsReadByIndex,
readMailbox,
writeToMailbox,
} from '../teammateMailbox.js'
@@ -405,10 +405,10 @@ function createInProcessCanUseTool(
if (msg && !msg.read) {
const parsed = isPermissionResponse(msg.text)
if (parsed && parsed.request_id === request.id) {
await markMessageAsReadByIdentity(
await markMessageAsReadByIndex(
identity.agentName,
identity.teamName,
msg,
i,
)
if (parsed.subtype === 'success') {
processMailboxPermissionResponse({
@@ -801,10 +801,10 @@ async function waitForNextPromptOrShutdown(
logForDebugging(
`[inProcessRunner] ${identity.agentName} received shutdown request from ${shutdownParsed?.from} (prioritized over ${skippedUnread} unread messages)`,
)
await markMessageAsReadByIdentity(
await markMessageAsReadByIndex(
identity.agentName,
identity.teamName,
msg,
shutdownIndex,
)
return {
type: 'shutdown_request',
@@ -839,10 +839,10 @@ async function waitForNextPromptOrShutdown(
logForDebugging(
`[inProcessRunner] ${identity.agentName} received new message from ${msg.from} (index ${selectedIndex})`,
)
await markMessageAsReadByIdentity(
await markMessageAsReadByIndex(
identity.agentName,
identity.teamName,
msg,
selectedIndex,
)
return {
type: 'new_message',

View File

@@ -7,8 +7,7 @@
* Note: Inboxes are keyed by agent name within a team.
*/
import { randomBytes } from 'crypto'
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod/v4'
import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js'
@@ -41,13 +40,6 @@ const LOCK_OPTIONS = {
},
}
export const MAX_MAILBOX_MESSAGES = 1_000
export const MAX_READ_MAILBOX_MESSAGES = 200
export const MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES = 2_000
export const MAX_MAILBOX_MESSAGE_TEXT_BYTES = 64 * 1024
export const MAX_MAILBOX_RETAINED_BYTES = 2 * 1024 * 1024
export const MAX_MAILBOX_FILE_BYTES = 4 * 1024 * 1024
export type TeammateMessage = {
from: string
text: string
@@ -57,223 +49,6 @@ export type TeammateMessage = {
summary?: string // 5-10 word summary shown as preview in the UI
}
function isJsonLikeMessage(text: string): boolean {
const trimmed = text.trimStart()
return trimmed.startsWith('{') || trimmed.startsWith('[')
}
function shouldRetainUnreadAsProtocolMessage(
message: TeammateMessage,
): boolean {
if (message.read) return false
if (isStructuredProtocolMessage(message.text)) return true
if (!isJsonLikeMessage(message.text)) return false
try {
const parsed = jsonParse(message.text)
return Boolean(
parsed &&
typeof parsed === 'object' &&
'type' in (parsed as Record<string, unknown>),
)
} catch {
return false
}
}
function sameMailboxMessage(a: TeammateMessage, b: TeammateMessage): boolean {
return a.from === b.from && a.timestamp === b.timestamp && a.text === b.text
}
function mailboxMessageStorageBytes(message: TeammateMessage): number {
return Buffer.byteLength(jsonStringify(message), 'utf8')
}
function assertMailboxMessageSize(message: TeammateMessage): void {
const textBytes = Buffer.byteLength(message.text, 'utf8')
if (textBytes > MAX_MAILBOX_MESSAGE_TEXT_BYTES) {
throw new Error(
`Mailbox message text exceeds ${MAX_MAILBOX_MESSAGE_TEXT_BYTES} bytes`,
)
}
}
function toMailboxMessage(value: unknown): TeammateMessage {
if (!value || typeof value !== 'object') {
throw new Error('Invalid mailbox message: expected object')
}
const record = value as Record<string, unknown>
if (
typeof record.from !== 'string' ||
typeof record.text !== 'string' ||
typeof record.timestamp !== 'string' ||
typeof record.read !== 'boolean'
) {
throw new Error('Invalid mailbox message shape')
}
const message: TeammateMessage = {
from: record.from,
text: record.text,
timestamp: record.timestamp,
read: record.read,
...(typeof record.color === 'string' ? { color: record.color } : {}),
...(typeof record.summary === 'string' ? { summary: record.summary } : {}),
}
assertMailboxMessageSize(message)
return message
}
function parseMailboxMessages(content: string): TeammateMessage[] {
const parsed = jsonParse(content)
if (!Array.isArray(parsed)) {
throw new Error('Invalid mailbox file: expected message array')
}
return parsed.map(toMailboxMessage)
}
async function readMailboxFile(inboxPath: string): Promise<string> {
const info = await stat(inboxPath)
if (info.size > MAX_MAILBOX_FILE_BYTES) {
throw new Error(
`Mailbox file exceeds ${MAX_MAILBOX_FILE_BYTES} bytes: ${inboxPath}`,
)
}
return readFile(inboxPath, 'utf-8')
}
async function readMailboxForMutation(
agentName: string,
teamName?: string,
): Promise<TeammateMessage[]> {
const inboxPath = getInboxPath(agentName, teamName)
return parseMailboxMessages(await readMailboxFile(inboxPath))
}
async function writeMailboxAtomic(
inboxPath: string,
content: string,
): Promise<void> {
const bytes = Buffer.byteLength(content, 'utf8')
if (bytes > MAX_MAILBOX_FILE_BYTES) {
throw new Error(
`Compacted mailbox still exceeds ${MAX_MAILBOX_FILE_BYTES} bytes`,
)
}
const tempPath = `${inboxPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
try {
await writeFile(tempPath, content, 'utf-8')
await rename(tempPath, inboxPath)
} catch (error) {
await unlink(tempPath).catch(() => undefined)
throw error
}
}
export function compactMailboxMessages(
messages: TeammateMessage[],
limits: {
maxMessages?: number
maxReadMessages?: number
maxUnreadProtocolMessages?: number
maxRetainedBytes?: number
} = {},
): TeammateMessage[] {
const maxMessages = limits.maxMessages ?? MAX_MAILBOX_MESSAGES
const maxReadMessages = limits.maxReadMessages ?? MAX_READ_MAILBOX_MESSAGES
const maxUnreadProtocolMessages =
limits.maxUnreadProtocolMessages ?? MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES
const maxRetainedBytes = limits.maxRetainedBytes ?? MAX_MAILBOX_RETAINED_BYTES
if (
maxRetainedBytes <= 0 ||
(maxMessages <= 0 && maxUnreadProtocolMessages <= 0)
) {
return []
}
const keepIndexes = new Set<number>()
let retainedBytes = 0
let keptUnreadProtocolMessages = 0
const tryKeep = (index: number): boolean => {
if (keepIndexes.has(index)) return true
const message = messages[index]
if (!message) return false
const bytes = mailboxMessageStorageBytes(message)
if (bytes > maxRetainedBytes || retainedBytes + bytes > maxRetainedBytes) {
return false
}
keepIndexes.add(index)
retainedBytes += bytes
return true
}
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message || !shouldRetainUnreadAsProtocolMessage(message)) continue
if (keptUnreadProtocolMessages >= maxUnreadProtocolMessages) continue
if (tryKeep(i)) keptUnreadProtocolMessages++
}
let keptNonProtocolMessages = 0
for (let i = messages.length - 1; i >= 0; i--) {
if (keptNonProtocolMessages >= maxMessages) break
const message = messages[i]
if (
message &&
!message.read &&
!shouldRetainUnreadAsProtocolMessage(message)
) {
if (tryKeep(i)) keptNonProtocolMessages++
}
}
let keptReadMessages = 0
for (let i = messages.length - 1; i >= 0; i--) {
if (keptNonProtocolMessages >= maxMessages) break
if (keptReadMessages >= maxReadMessages) break
const message = messages[i]
if (message?.read) {
if (tryKeep(i)) {
keptReadMessages++
keptNonProtocolMessages++
}
}
}
return messages.filter((_message, index) => keepIndexes.has(index))
}
function logUnreadMailboxEvictions(
original: TeammateMessage[],
compacted: TeammateMessage[],
context: string,
): void {
const kept = new Set(compacted)
const unreadEvicted = original.filter(message => {
return !message.read && !kept.has(message)
})
if (unreadEvicted.length === 0) return
const protocolEvicted = count(unreadEvicted, message =>
shouldRetainUnreadAsProtocolMessage(message),
)
logError(
new Error(
`[TeammateMailbox] Compacted ${unreadEvicted.length} unread message(s) in ${context}; protocol_or_unknown=${protocolEvicted}`,
),
)
}
async function writeCompactedMailbox(
inboxPath: string,
messages: TeammateMessage[],
context: string,
): Promise<void> {
const compacted = compactMailboxMessages(messages)
logUnreadMailboxEvictions(messages, compacted, context)
await writeMailboxAtomic(inboxPath, jsonStringify(compacted, null, 2))
}
/**
* Get the path to a teammate's inbox file
* Structure: ~/.claude/teams/{team_name}/inboxes/{agent_name}.json
@@ -314,7 +89,8 @@ export async function readMailbox(
logForDebugging(`[TeammateMailbox] readMailbox: path=${inboxPath}`)
try {
const messages = parseMailboxMessages(await readMailboxFile(inboxPath))
const content = await readFile(inboxPath, 'utf-8')
const messages = jsonParse(content) as TeammateMessage[]
logForDebugging(
`[TeammateMailbox] readMailbox: read ${messages.length} message(s)`,
)
@@ -327,7 +103,7 @@ export async function readMailbox(
}
logForDebugging(`Failed to read inbox for ${agentName}: ${error}`)
logError(error)
throw error
return []
}
}
@@ -380,7 +156,7 @@ export async function writeToMailbox(
`[TeammateMailbox] writeToMailbox: failed to create inbox file: ${error}`,
)
logError(error)
throw error
return
}
}
@@ -392,23 +168,22 @@ export async function writeToMailbox(
})
// Re-read messages after acquiring lock to get the latest state
const messages = await readMailboxForMutation(recipientName, teamName)
const messages = await readMailbox(recipientName, teamName)
const newMessage = toMailboxMessage({
const newMessage: TeammateMessage = {
...message,
read: false,
})
}
messages.push(newMessage)
await writeCompactedMailbox(inboxPath, messages, 'writeToMailbox')
await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
logForDebugging(
`[TeammateMailbox] Wrote message to ${recipientName}'s inbox from ${message.from}`,
)
} catch (error) {
logForDebugging(`Failed to write to inbox for ${recipientName}: ${error}`)
logError(error)
throw error
} finally {
if (release) {
await release()
@@ -447,7 +222,7 @@ export async function markMessageAsReadByIndex(
logForDebugging(`[TeammateMailbox] markMessageAsReadByIndex: lock acquired`)
// Re-read messages after acquiring lock to get the latest state
const messages = await readMailboxForMutation(agentName, teamName)
const messages = await readMailbox(agentName, teamName)
logForDebugging(
`[TeammateMailbox] markMessageAsReadByIndex: read ${messages.length} messages after lock`,
)
@@ -469,7 +244,7 @@ export async function markMessageAsReadByIndex(
messages[messageIndex] = { ...message, read: true }
await writeCompactedMailbox(inboxPath, messages, 'markMessageAsReadByIndex')
await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
logForDebugging(
`[TeammateMailbox] markMessageAsReadByIndex: marked message at index ${messageIndex} as read`,
)
@@ -495,46 +270,6 @@ export async function markMessageAsReadByIndex(
}
}
export async function markMessageAsReadByIdentity(
agentName: string,
teamName: string | undefined,
expectedMessage: TeammateMessage,
): Promise<boolean> {
const inboxPath = getInboxPath(agentName, teamName)
const lockFilePath = `${inboxPath}.lock`
let release: (() => Promise<void>) | undefined
try {
release = await lockfile.lock(inboxPath, {
lockfilePath: lockFilePath,
...LOCK_OPTIONS,
})
const messages = await readMailboxForMutation(agentName, teamName)
const messageIndex = messages.findIndex(message => {
return !message.read && sameMailboxMessage(message, expectedMessage)
})
if (messageIndex < 0) return false
messages[messageIndex] = { ...messages[messageIndex]!, read: true }
await writeCompactedMailbox(
inboxPath,
messages,
'markMessageAsReadByIdentity',
)
return true
} catch (error) {
const code = getErrnoCode(error)
if (code === 'ENOENT') return false
logError(error)
return false
} finally {
if (release) {
await release()
}
}
}
/**
* Mark all messages in a teammate's inbox as read
* Uses file locking to prevent race conditions
@@ -562,7 +297,7 @@ export async function markMessagesAsRead(
logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock acquired`)
// Re-read messages after acquiring lock to get the latest state
const messages = await readMailboxForMutation(agentName, teamName)
const messages = await readMailbox(agentName, teamName)
logForDebugging(
`[TeammateMailbox] markMessagesAsRead: read ${messages.length} messages after lock`,
)
@@ -582,7 +317,7 @@ export async function markMessagesAsRead(
// messages comes from jsonParse — fresh, unshared objects safe to mutate
for (const m of messages) m.read = true
await writeCompactedMailbox(inboxPath, messages, 'markMessagesAsRead')
await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
logForDebugging(
`[TeammateMailbox] markMessagesAsRead: WROTE ${unreadCount} message(s) as read to ${inboxPath}`,
)
@@ -1379,7 +1114,7 @@ export async function markMessagesAsReadByPredicate(
...LOCK_OPTIONS,
})
const messages = await readMailboxForMutation(agentName, teamName)
const messages = await readMailbox(agentName, teamName)
if (messages.length === 0) {
return
}
@@ -1388,11 +1123,7 @@ export async function markMessagesAsReadByPredicate(
!m.read && predicate(m) ? { ...m, read: true } : m,
)
await writeCompactedMailbox(
inboxPath,
updatedMessages,
'markMessagesAsReadByPredicate',
)
await writeFile(inboxPath, jsonStringify(updatedMessages, null, 2), 'utf-8')
} catch (error) {
const code = getErrnoCode(error)
if (code === 'ENOENT') {
@@ -1430,12 +1161,7 @@ export function getLastPeerDmSummary(messages: Message[]): string | undefined {
if (!Array.isArray(content)) continue
for (const block of content) {
if (typeof block === 'string') continue
const b = block as unknown as {
type: string
name?: string
input?: Record<string, unknown>
[key: string]: unknown
}
const b = block as unknown as { type: string; name?: string; input?: Record<string, unknown>; [key: string]: unknown }
if (
b.type === 'tool_use' &&
b.name === SEND_MESSAGE_TOOL_NAME &&
@@ -1451,7 +1177,7 @@ export function getLastPeerDmSummary(messages: Message[]): string | undefined {
const to = b.input.to as string
const summary =
'summary' in b.input && typeof b.input.summary === 'string'
? (b.input.summary as string)
? b.input.summary as string
: (b.input.message as string).slice(0, 80)
return `[to ${to}] ${summary}`
}

View File

@@ -16,8 +16,7 @@ import { errorMessage, isFsInaccessible } from './errors.js'
import { isProcessRunning } from './genericProcessUtils.js'
import { jsonParse, jsonStringify } from './slowOperations.js'
import type { SessionKind } from './concurrentSessions.js'
import { MAX_UDS_FRAME_BYTES, type UdsMessage } from './udsMessaging.js'
import { attachUdsResponseReader, getChunkBytes } from './udsResponseReader.js'
import type { UdsMessage } from './udsMessaging.js'
// ---------------------------------------------------------------------------
// Types
@@ -36,19 +35,6 @@ export type PeerSession = {
alive: boolean
}
export class UdsPeerConnectionError extends Error {
readonly socketPath: string
constructor(socketPath: string, cause: unknown) {
super(
`Failed to connect to peer at ${socketPath}: ${errorMessage(cause)}`,
{ cause },
)
this.name = 'UdsPeerConnectionError'
this.socketPath = socketPath
}
}
// ---------------------------------------------------------------------------
// Session directory
// ---------------------------------------------------------------------------
@@ -118,14 +104,9 @@ export async function listAllLiveSessions(): Promise<PeerSession[]> {
*/
export async function listPeers(): Promise<PeerSession[]> {
const all = await listAllLiveSessions()
return all.filter(s => s.pid !== process.pid && s.messagingSocketPath != null)
}
async function findAuthTokenForSocketPath(
socketPath: string,
): Promise<string | undefined> {
const { readUdsCapabilityToken } = await import('./udsMessaging.js')
return readUdsCapabilityToken(socketPath)
return all.filter(
s => s.pid !== process.pid && s.messagingSocketPath != null,
)
}
// ---------------------------------------------------------------------------
@@ -136,21 +117,10 @@ async function findAuthTokenForSocketPath(
* Probe a UDS socket to check if a server is listening (ping/pong).
* Returns true if the peer responds within the timeout.
*/
export async function isPeerAlive(
socketPath: string,
timeoutMs = 3000,
authToken?: string,
): Promise<boolean> {
const token = authToken ?? (await findAuthTokenForSocketPath(socketPath))
if (!token) return false
return new Promise<boolean>(resolve => {
export async function isPeerAlive(socketPath: string, timeoutMs = 3000): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const conn = createConnection(socketPath, () => {
const ping: UdsMessage = {
type: 'ping',
ts: new Date().toISOString(),
meta: { authToken: token },
}
const ping: UdsMessage = { type: 'ping', ts: new Date().toISOString() }
conn.write(jsonStringify(ping) + '\n')
})
@@ -165,19 +135,7 @@ export async function isPeerAlive(
}, timeoutMs)
let buffer = ''
conn.on('data', chunk => {
if (
Buffer.byteLength(buffer, 'utf8') + getChunkBytes(chunk) >
MAX_UDS_FRAME_BYTES
) {
if (!resolved) {
resolved = true
clearTimeout(timer)
conn.destroy()
resolve(false)
}
return
}
conn.on('data', (chunk) => {
buffer += chunk.toString()
if (buffer.includes('"pong"')) {
if (!resolved) {
@@ -206,15 +164,7 @@ export async function isPeerAlive(
export async function sendToUdsSocket(
targetSocketPath: string,
message: string | Record<string, unknown>,
timeoutMs = 5000,
): Promise<void> {
const { parseUdsTarget } = await import('./udsMessaging.js')
const target = parseUdsTarget(targetSocketPath)
const authToken = await findAuthTokenForSocketPath(target.socketPath)
if (!authToken) {
throw new Error(`No auth token found for peer at ${target.socketPath}`)
}
const data = typeof message === 'string' ? message : jsonStringify(message)
const udsMsg: UdsMessage = {
type: 'text',
@@ -227,87 +177,35 @@ export async function sendToUdsSocket(
udsMsg.from = getUdsMessagingSocketPath()
return new Promise<void>((resolve, reject) => {
let settled = false
let conn: ReturnType<typeof createConnection>
const finish = (error?: Error): void => {
if (settled) return
settled = true
if (error) {
conn.destroy(error)
reject(error)
} else {
const conn = createConnection(targetSocketPath, () => {
conn.write(jsonStringify(udsMsg) + '\n', (err) => {
conn.end()
resolve()
}
}
conn = createConnection(target.socketPath, () => {
udsMsg.meta = { ...udsMsg.meta, authToken }
conn.write(jsonStringify(udsMsg) + '\n', err => {
if (err) finish(err)
if (err) reject(err)
else resolve()
})
})
attachUdsResponseReader(conn, {
maxFrameBytes: MAX_UDS_FRAME_BYTES,
onSettled: finish,
formatSocketError: err =>
new UdsPeerConnectionError(target.socketPath, err),
conn.on('error', (err) => {
reject(new Error(`Failed to connect to peer at ${targetSocketPath}: ${errorMessage(err)}`))
})
conn.setTimeout(timeoutMs, () => {
finish(
new UdsPeerConnectionError(
target.socketPath,
new Error('Connection timed out'),
),
)
conn.setTimeout(5000, () => {
conn.destroy(new Error('Connection timed out'))
})
})
}
/**
* Connect to a peer and return the raw socket for bidirectional communication.
* The caller owns the post-connect lifecycle through onSocketError, which is
* attached before the Promise resolves so peer socket errors cannot be
* swallowed or surface through a listener handoff window.
* Pre-connect failures reject with UdsPeerConnectionError.
* This only opens the transport; callers still own any capability handshake.
* The caller is responsible for managing the connection lifecycle.
*/
export function connectToPeer(
socketPath: string,
onSocketError: (error: Error) => void,
timeoutMs = 5000,
): Promise<Socket> {
export function connectToPeer(socketPath: string): Promise<Socket> {
return new Promise<Socket>((resolve, reject) => {
const conn = createConnection(socketPath)
let settled = false
const timeout = setTimeout(
fail,
timeoutMs,
new Error('Connection timed out'),
)
function cleanupListeners(): void {
clearTimeout(timeout)
conn.off('error', fail)
}
function fail(cause: unknown): void {
if (settled) {
return
}
settled = true
cleanupListeners()
conn.destroy()
reject(new UdsPeerConnectionError(socketPath, cause))
}
conn.once('connect', () => {
if (settled) {
return
}
settled = true
cleanupListeners()
conn.on('error', onSocketError)
const conn = createConnection(socketPath, () => {
resolve(conn)
})
conn.on('error', fail)
conn.on('error', reject)
conn.setTimeout(5000, () => {
conn.destroy(new Error('Connection timed out'))
})
})
}

View File

@@ -8,26 +8,14 @@
* but can be overridden via --messaging-socket-path.
*/
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
import { createServer, type Server, type Socket } from 'net'
import {
chmod,
lstat,
mkdir,
open,
readFile,
rename,
unlink,
} from 'fs/promises'
import { mkdir, unlink } from 'fs/promises'
import { dirname, join } from 'path'
import { tmpdir } from 'os'
import { registerCleanup } from './cleanupRegistry.js'
import { logForDebugging } from './debug.js'
import { errorMessage } from './errors.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { attachNdjsonFramer } from './ndjsonFramer.js'
import { attachUdsResponseReader } from './udsResponseReader.js'
import { logError } from './log.js'
import { jsonParse, jsonStringify } from './slowOperations.js'
// ---------------------------------------------------------------------------
@@ -39,7 +27,6 @@ export type UdsMessageType =
| 'notification'
| 'query'
| 'response'
| 'error'
| 'ping'
| 'pong'
@@ -73,17 +60,6 @@ let onEnqueueCb: (() => void) | null = null
const clients = new Set<Socket>()
const inbox: UdsInboxEntry[] = []
let nextId = 1
let defaultSocketPath: string | null = null
let authToken: string | null = null
let capabilityFilePath: string | null = null
let inboxBytes = 0
export const MAX_UDS_INBOX_ENTRIES = 1_000
export const MAX_UDS_FRAME_BYTES = 64 * 1024
export const MAX_UDS_INBOX_BYTES = 2 * 1024 * 1024
export const MAX_UDS_CLIENTS = 128
export const UDS_AUTH_TIMEOUT_MS = 2_000
export const UDS_IDLE_TIMEOUT_MS = 30_000
// ---------------------------------------------------------------------------
// Public API — socket path helpers
@@ -98,19 +74,10 @@ export const UDS_IDLE_TIMEOUT_MS = 30_000
* transparently, but we use the pipe format on Windows for Node.js compat.
*/
export function getDefaultUdsSocketPath(): string {
if (defaultSocketPath) return defaultSocketPath
const nonce = randomBytes(16).toString('hex')
if (process.platform === 'win32') {
defaultSocketPath = `\\\\.\\pipe\\claude-code-${process.pid}-${nonce}`
return defaultSocketPath
return `\\\\.\\pipe\\claude-code-${process.pid}`
}
defaultSocketPath = join(
tmpdir(),
'claude-code-socks',
`${process.pid}-${nonce}`,
'messaging.sock',
)
return defaultSocketPath
return join(tmpdir(), 'claude-code-socks', `${process.pid}.sock`)
}
/**
@@ -121,153 +88,6 @@ export function getUdsMessagingSocketPath(): string | undefined {
return socketPath ?? undefined
}
export function formatUdsAddress(socket: string): string {
return `uds:${socket}`
}
export function parseUdsTarget(target: string): {
socketPath: string
} {
if (target.includes('#token=')) {
throw new Error(
'UDS target must not include an inline auth token; use the ListPeers address',
)
}
return { socketPath: target }
}
function getCapabilityDir(): string {
return join(getClaudeConfigHomeDir(), 'messaging-capabilities')
}
function getCapabilityPath(socket: string): string {
const digest = createHash('sha256').update(socket).digest('hex')
return join(getCapabilityDir(), `${digest}.json`)
}
function isNotFound(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
)
}
async function assertPrivateCapabilityDir(dir: string): Promise<void> {
let stat: Awaited<ReturnType<typeof lstat>>
try {
stat = await lstat(dir)
} catch (error) {
if (!isNotFound(error)) throw error
await mkdir(dir, { recursive: true, mode: 0o700 })
stat = await lstat(dir)
}
assertPrivateDirectory(stat, dir, 'capability directory')
await chmod(dir, 0o700)
}
function assertPrivateDirectory(
stat: Awaited<ReturnType<typeof lstat>>,
dir: string,
label: string,
): void {
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(
`[udsMessaging] ${label} is not a private directory: ${dir}`,
)
}
if (process.platform !== 'win32') {
const broadMode = Number(stat.mode) & 0o077
if (broadMode !== 0) {
throw new Error(
`[udsMessaging] ${label} permissions are too broad: ${dir}`,
)
}
if (
typeof process.getuid === 'function' &&
Number(stat.uid) !== process.getuid()
) {
throw new Error(
`[udsMessaging] ${label} owner does not match current user: ${dir}`,
)
}
}
}
async function writePrivateFileExclusive(
path: string,
content: string,
): Promise<void> {
const handle = await open(path, 'wx', 0o600)
try {
await handle.writeFile(content, 'utf-8')
} finally {
await handle.close()
}
await chmod(path, 0o600)
}
async function ensureSocketParent(path: string): Promise<void> {
const dir = dirname(path)
try {
const stat = await lstat(dir)
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(
`[udsMessaging] socket parent is not a directory: ${dir}`,
)
}
assertPrivateDirectory(stat, dir, 'socket parent')
return
} catch (error) {
if (!isNotFound(error)) throw error
}
await mkdir(dir, { recursive: true, mode: 0o700 })
await chmod(dir, 0o700)
}
async function writeCapabilityFile(
socket: string,
token: string,
): Promise<void> {
const dir = getCapabilityDir()
await assertPrivateCapabilityDir(dir)
const target = getCapabilityPath(socket)
const temp = `${target}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
try {
await writePrivateFileExclusive(
temp,
jsonStringify({ socketPath: socket, authToken: token }),
)
await rename(temp, target)
} catch (error) {
try {
await unlink(temp)
} catch {
// Temp file may not exist if exclusive creation failed.
}
throw error
}
capabilityFilePath = target
}
export async function readUdsCapabilityToken(
socket: string,
): Promise<string | undefined> {
try {
const parsed = jsonParse(
await readFile(getCapabilityPath(socket), 'utf-8'),
) as Record<string, unknown>
if (parsed.socketPath === socket && typeof parsed.authToken === 'string') {
return parsed.authToken
}
} catch {
// Missing or unreadable capability file means the peer is not addressable.
}
return undefined
}
// ---------------------------------------------------------------------------
// Inbox
// ---------------------------------------------------------------------------
@@ -281,121 +101,16 @@ export function setOnEnqueue(cb: (() => void) | null): void {
}
/**
* Drain all pending inbox messages and release retained history.
* Drain all pending inbox messages, marking them processed.
*/
export function drainInbox(): UdsInboxEntry[] {
const pending = inbox.splice(0, inbox.length)
inboxBytes = 0
const pending = inbox.filter(e => e.status === 'pending')
for (const entry of pending) {
entry.status = 'processed'
}
return pending
}
function getMessageBytes(message: UdsMessage): number {
return Buffer.byteLength(jsonStringify(message), 'utf8')
}
function enqueueInboxEntry(entry: UdsInboxEntry): boolean {
const entryBytes = getMessageBytes(entry.message)
if (
entryBytes > MAX_UDS_FRAME_BYTES ||
inbox.length >= MAX_UDS_INBOX_ENTRIES ||
inboxBytes + entryBytes > MAX_UDS_INBOX_BYTES
) {
logError(
new Error(
`[udsMessaging] inbox full (${inbox.length}/${MAX_UDS_INBOX_ENTRIES}, ${inboxBytes}/${MAX_UDS_INBOX_BYTES} bytes); dropping message type=${entry.message.type}`,
),
)
return false
}
inbox.push(entry)
inboxBytes += entryBytes
return true
}
function ensureAuthToken(): string {
if (!authToken) {
authToken = randomBytes(32).toString('hex')
}
return authToken
}
function getMessageAuthToken(message: UdsMessage): string | undefined {
const token = message.meta?.authToken
return typeof token === 'string' ? token : undefined
}
function isAuthorizedMessage(message: UdsMessage): boolean {
const provided = getMessageAuthToken(message)
if (!provided || !authToken) return false
const providedBuffer = Buffer.from(provided, 'utf8')
const expectedBuffer = Buffer.from(authToken, 'utf8')
if (providedBuffer.length !== expectedBuffer.length) return false
return timingSafeEqual(providedBuffer, expectedBuffer)
}
function writeSocketMessage(socket: Socket, message: UdsMessage): void {
if (socket.destroyed) return
socket.write(jsonStringify(message) + '\n')
}
function writeSocketMessageAndDestroy(socket: Socket, message: UdsMessage): void {
if (socket.destroyed) return
socket.write(jsonStringify(message) + '\n', () => {
if (!socket.destroyed) socket.destroy()
})
}
function writeSocketErrorAndDestroy(socket: Socket, data: string): void {
writeSocketMessageAndDestroy(socket, {
type: 'error',
data,
ts: new Date().toISOString(),
})
}
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
const maybeUnref = (timer as { unref?: () => void }).unref
if (typeof maybeUnref === 'function') {
maybeUnref.call(timer)
}
}
async function closeServer(serverToClose: Server): Promise<void> {
await new Promise<void>(resolve => {
serverToClose.close(() => resolve())
})
}
async function removeSocketPath(path: string): Promise<void> {
if (process.platform === 'win32') return
try {
await unlink(path)
} catch {
// Already gone.
}
}
function stripAuthToken(message: UdsMessage): UdsMessage {
const { authToken: _authToken, ...metaWithoutAuth } = message.meta ?? {}
return {
...message,
meta: Object.keys(metaWithoutAuth).length > 0 ? metaWithoutAuth : undefined,
}
}
function withRequestAuthToken(message: UdsMessage, token: string): UdsMessage {
return {
...message,
meta: {
...message.meta,
authToken: token,
},
}
}
// ---------------------------------------------------------------------------
// Server
// ---------------------------------------------------------------------------
@@ -417,7 +132,7 @@ export async function startUdsMessaging(
// Ensure parent directory exists (skip on Windows — pipe paths aren't files)
if (process.platform !== 'win32') {
await ensureSocketParent(path)
await mkdir(dirname(path), { recursive: true })
}
// Clean up stale socket file (skip on Windows — pipe paths aren't files)
@@ -429,214 +144,69 @@ export async function startUdsMessaging(
}
}
const token = ensureAuthToken()
let startedServer: Server | null = null
let exportedSocketEnv = false
try {
await new Promise<void>((resolve, reject) => {
const srv = createServer(socket => {
if (clients.size >= MAX_UDS_CLIENTS) {
logForDebugging(
`[udsMessaging] rejected client: ${clients.size}/${MAX_UDS_CLIENTS} clients already connected`,
)
socket.destroy()
return
}
clients.add(socket)
logForDebugging(
`[udsMessaging] client connected (total: ${clients.size})`,
)
let authenticated = false
let closing = false
const closeWithError = (data: string): void => {
if (closing || socket.destroyed) return
closing = true
socket.pause()
writeSocketErrorAndDestroy(socket, data)
}
const authTimer = setTimeout(() => {
if (authenticated || socket.destroyed) return
logForDebugging('[udsMessaging] closing unauthenticated idle client')
closeWithError('authentication timeout')
}, UDS_AUTH_TIMEOUT_MS)
unrefTimer(authTimer)
socket.setTimeout(UDS_IDLE_TIMEOUT_MS, () => {
logForDebugging('[udsMessaging] closing idle client')
closeWithError('idle timeout')
})
socketPath = path
attachNdjsonFramer<UdsMessage>(
socket,
msg => {
if (!isAuthorizedMessage(msg)) {
logForDebugging(
`[udsMessaging] rejected unauthenticated message type=${msg.type}`,
)
closeWithError('unauthorized')
return
}
if (!authenticated) {
authenticated = true
clearTimeout(authTimer)
}
await new Promise<void>((resolve, reject) => {
const srv = createServer(socket => {
clients.add(socket)
logForDebugging(
`[udsMessaging] client connected (total: ${clients.size})`,
)
// Handle ping with automatic pong
if (msg.type === 'ping') {
writeSocketMessage(socket, {
type: 'pong',
from: socketPath ?? undefined,
ts: new Date().toISOString(),
})
return
}
// Enqueue into inbox
const sanitizedMessage = stripAuthToken(msg)
const entry: UdsInboxEntry = {
id: `uds-${nextId++}`,
message: sanitizedMessage,
receivedAt: Date.now(),
status: 'pending',
}
if (!enqueueInboxEntry(entry)) {
closeWithError('inbox full')
return
}
logForDebugging(
`[udsMessaging] enqueued message type=${msg.type} from=${msg.from ?? 'unknown'}`,
)
writeSocketMessage(socket, {
type: 'response',
data: 'ok',
attachNdjsonFramer<UdsMessage>(
socket,
msg => {
// Handle ping with automatic pong
if (msg.type === 'ping') {
const pong: UdsMessage = {
type: 'pong',
from: socketPath ?? undefined,
ts: new Date().toISOString(),
meta: { id: entry.id },
})
onEnqueueCb?.()
},
text => jsonParse(text) as UdsMessage,
{
maxFrameBytes: MAX_UDS_FRAME_BYTES,
onFrameError: error => {
logForDebugging(`[udsMessaging] ${error.message}`)
closeWithError(error.message)
},
onInvalidFrame: error => {
logForDebugging(
`[udsMessaging] invalid client frame: ${errorMessage(error)}`,
)
closeWithError('invalid frame')
},
destroyOnFrameError: false,
},
)
}
if (!socket.destroyed) {
socket.write(jsonStringify(pong) + '\n')
}
return
}
socket.on('close', () => {
clearTimeout(authTimer)
clients.delete(socket)
})
// Enqueue into inbox
const entry: UdsInboxEntry = {
id: `uds-${nextId++}`,
message: msg,
receivedAt: Date.now(),
status: 'pending',
}
inbox.push(entry)
logForDebugging(
`[udsMessaging] enqueued message type=${msg.type} from=${msg.from ?? 'unknown'}`,
)
onEnqueueCb?.()
},
text => jsonParse(text) as UdsMessage,
)
socket.on('error', err => {
clearTimeout(authTimer)
clients.delete(socket)
logForDebugging(`[udsMessaging] client error: ${errorMessage(err)}`)
})
socket.on('close', () => {
clients.delete(socket)
})
const rejectBeforeListen = (error: Error): void => {
reject(error)
}
const logRuntimeError = (error: Error): void => {
logForDebugging(
`[udsMessaging] server error on ${path}${opts?.isExplicit ? ' (explicit)' : ''}: ${errorMessage(error)}`,
)
}
srv.once('error', rejectBeforeListen)
srv.listen(path, () => {
void (async () => {
try {
if (process.platform !== 'win32') {
// Restrict socket permissions to owner-only. On macOS with
// Node.js v22, the listen callback may fire before the socket
// file is visible on disk (observed with nested tmpdir paths).
// The parent directory is already 0o700, so skipping chmod when
// the file is not yet visible is safe.
try {
await chmod(path, 0o600)
} catch (err: unknown) {
if (
!(
err instanceof Error &&
(err as NodeJS.ErrnoException).code === 'ENOENT'
)
) {
throw err
}
logForDebugging(
`[udsMessaging] chmod skipped: socket file not yet visible at ${path}`,
)
}
}
srv.off('error', rejectBeforeListen)
srv.on('error', logRuntimeError)
server = srv
startedServer = srv
resolve()
} catch (error) {
srv.off('error', rejectBeforeListen)
const closeError =
error instanceof Error ? error : new Error(errorMessage(error))
let rejected = false
const rejectOnce = (): void => {
if (rejected) return
rejected = true
reject(closeError)
}
const fallback = setTimeout(rejectOnce, 1_000)
unrefTimer(fallback)
srv.close(() => {
clearTimeout(fallback)
rejectOnce()
})
}
})()
socket.on('error', err => {
clients.delete(socket)
logForDebugging(`[udsMessaging] client error: ${errorMessage(err)}`)
})
})
await writeCapabilityFile(path, token)
socketPath = path
// Export so child processes can discover the socket only after the
// capability file exists and the listener is ready.
process.env.CLAUDE_CODE_MESSAGING_SOCKET = path
exportedSocketEnv = true
logForDebugging(
`[udsMessaging] server listening on ${path}${opts?.isExplicit ? ' (explicit)' : ''}`,
)
} catch (error) {
if (capabilityFilePath) {
try {
await unlink(capabilityFilePath)
} catch {
// Already gone.
}
capabilityFilePath = null
}
if (startedServer) {
await closeServer(startedServer)
}
if (server === startedServer) {
server = null
}
await removeSocketPath(path)
if (exportedSocketEnv) {
delete process.env.CLAUDE_CODE_MESSAGING_SOCKET
}
socketPath = null
defaultSocketPath = null
authToken = null
throw error
}
srv.on('error', reject)
srv.listen(path, () => {
server = srv
// Export so child processes can discover the socket
process.env.CLAUDE_CODE_MESSAGING_SOCKET = path
logForDebugging(
`[udsMessaging] server listening on ${path}${opts?.isExplicit ? ' (explicit)' : ''}`,
)
resolve()
})
})
// Register cleanup so the socket file is removed on exit
registerCleanup(async () => {
@@ -648,7 +218,6 @@ export async function startUdsMessaging(
* Stop the UDS messaging server and clean up the socket file.
*/
export async function stopUdsMessaging(): Promise<void> {
defaultSocketPath = null
if (!server) return
// Close all connected clients
@@ -661,27 +230,21 @@ export async function stopUdsMessaging(): Promise<void> {
server!.close(() => resolve())
})
server = null
inbox.length = 0
inboxBytes = 0
onEnqueueCb = null
// Remove socket file (skip on Windows — pipe paths aren't files)
if (socketPath) {
await removeSocketPath(socketPath)
if (process.platform !== 'win32') {
try {
await unlink(socketPath)
} catch {
// Already gone
}
}
delete process.env.CLAUDE_CODE_MESSAGING_SOCKET
logForDebugging(
`[udsMessaging] server stopped, socket removed: ${socketPath}`,
)
socketPath = null
authToken = null
}
if (capabilityFilePath) {
try {
await unlink(capabilityFilePath)
} catch {
// Already gone
}
capabilityFilePath = null
}
}
@@ -692,50 +255,23 @@ export async function stopUdsMessaging(): Promise<void> {
export async function sendUdsMessage(
targetSocketPath: string,
message: UdsMessage,
opts: { authToken?: string } = {},
): Promise<void> {
const { createConnection } = await import('net')
const token = opts.authToken ?? authToken
if (!token) {
throw new Error('Cannot send UDS message without auth token')
}
const outbound = withRequestAuthToken(
{
...message,
from: message.from ?? socketPath ?? undefined,
ts: message.ts ?? new Date().toISOString(),
},
token,
)
message.from = message.from ?? socketPath ?? undefined
message.ts = message.ts ?? new Date().toISOString()
return new Promise<void>((resolve, reject) => {
let settled = false
let conn: ReturnType<typeof createConnection>
const finish = (error?: Error): void => {
if (settled) return
settled = true
if (error) {
conn.destroy(error)
reject(error)
} else {
const conn = createConnection(targetSocketPath, () => {
conn.write(jsonStringify(message) + '\n', err => {
conn.end()
resolve()
}
}
conn = createConnection(targetSocketPath, () => {
conn.write(jsonStringify(outbound) + '\n', err => {
if (err) finish(err)
if (err) reject(err)
else resolve()
})
})
attachUdsResponseReader(conn, {
maxFrameBytes: MAX_UDS_FRAME_BYTES,
acceptPong: true,
onSettled: finish,
})
conn.on('error', reject)
// Timeout so we don't hang on unreachable sockets
conn.setTimeout(5000, () => {
finish(new Error('Connection timed out'))
conn.destroy(new Error('Connection timed out'))
})
})
}

View File

@@ -1,120 +0,0 @@
import type { Socket } from 'net'
import { StringDecoder } from 'node:string_decoder'
import { errorMessage } from './errors.js'
import { jsonParse } from './slowOperations.js'
import type { UdsMessage } from './udsMessaging.js'
type UdsResponseReaderOptions = {
maxFrameBytes: number
acceptPong?: boolean
onSettled: (error?: Error) => void
formatSocketError?: (error: unknown) => Error
}
export function getChunkBytes(chunk: string | Buffer): number {
return typeof chunk === 'string'
? Buffer.byteLength(chunk, 'utf8')
: chunk.byteLength
}
function parseResponseLine(line: string): UdsMessage {
try {
return jsonParse(line) as UdsMessage
} catch {
throw new Error('Invalid UDS response frame')
}
}
export function attachUdsResponseReader(
socket: Socket,
options: UdsResponseReaderOptions,
): void {
let buffer = ''
let bufferBytes = 0
let settled = false
const decoder = new StringDecoder('utf8')
function cleanupListeners(): void {
socket.off('data', onData)
socket.off('error', onError)
socket.off('end', onEnd)
socket.off('close', onClose)
}
function finish(error?: Error): void {
if (settled) return
settled = true
buffer = ''
bufferBytes = 0
cleanupListeners()
if (error) {
socket.destroy()
} else {
socket.end()
}
options.onSettled(error)
}
function onData(chunk: Buffer): void {
const decoded = decoder.write(chunk)
const decodedBytes = Buffer.byteLength(decoded, 'utf8')
if (bufferBytes + decodedBytes > options.maxFrameBytes) {
finish(new Error('UDS response frame exceeded size limit'))
return
}
buffer += decoded
bufferBytes += decodedBytes
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex)
const consumed = buffer.slice(0, newlineIndex + 1)
buffer = buffer.slice(newlineIndex + 1)
bufferBytes -= Buffer.byteLength(consumed, 'utf8')
if (!line.trim()) {
newlineIndex = buffer.indexOf('\n')
continue
}
let response: UdsMessage
try {
response = parseResponseLine(line)
} catch (error) {
finish(error instanceof Error ? error : new Error(errorMessage(error)))
return
}
if (
response.type === 'response' ||
(options.acceptPong === true && response.type === 'pong')
) {
finish()
return
}
if (response.type === 'error') {
finish(new Error(response.data ?? 'UDS receiver rejected message'))
return
}
newlineIndex = buffer.indexOf('\n')
}
}
function onError(error: Error): void {
finish(
options.formatSocketError?.(error) ??
(error instanceof Error ? error : new Error(errorMessage(error))),
)
}
function onEnd(): void {
finish(new Error('UDS socket ended before response'))
}
function onClose(hadError: boolean): void {
if (hadError) return
finish(new Error('UDS socket closed before response'))
}
socket.on('data', onData)
socket.on('error', onError)
socket.on('end', onEnd)
socket.on('close', onClose)
}