mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
Compare commits
6 Commits
v1.10.9
...
feature/la
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fcdcd6018 | ||
|
|
4cbef9667d | ||
|
|
c6338917e5 | ||
|
|
bcbb8a6e93 | ||
|
|
3fb48ec106 | ||
|
|
36bf4f260f |
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 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.10.9",
|
"version": "1.10.4",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ export const DEFAULT_BUILD_FEATURES = [
|
|||||||
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
|
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
|
||||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||||
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
|
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
|
||||||
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
||||||
'KAIROS', // Kairos 定时任务系统核心
|
'KAIROS', // Kairos 定时任务系统核心
|
||||||
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
||||||
// 'LAN_PIPES', // 依赖 UDS_INBOX(已随 UDS_INBOX 恢复)
|
'LAN_PIPES', // 依赖 UDS_INBOX(已随 UDS_INBOX 恢复)
|
||||||
'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill)
|
'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill)
|
||||||
'TEMPLATES', // 模板任务(new/list/reply 子命令)
|
'TEMPLATES', // 模板任务(new/list/reply 子命令)
|
||||||
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
||||||
@@ -68,7 +68,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
|||||||
'DIRECT_CONNECT', // 直连模式(claude server / claude open)
|
'DIRECT_CONNECT', // 直连模式(claude server / claude open)
|
||||||
// Skill search & learning
|
// Skill search & learning
|
||||||
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills)
|
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills)
|
||||||
// 'SKILL_LEARNING', // projectContext cache 无淘汰机制(非 GB 级主因)
|
'SKILL_LEARNING', // projectContext cache 无淘汰机制(非 GB 级主因)
|
||||||
// P3: poor mode
|
// P3: poor mode
|
||||||
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
||||||
// Team Memory
|
// Team Memory
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import type {
|
|||||||
CacheSafeParams,
|
CacheSafeParams,
|
||||||
ForkedAgentResult,
|
ForkedAgentResult,
|
||||||
} from '../../../utils/forkedAgent.js'
|
} from '../../../utils/forkedAgent.js'
|
||||||
import {
|
import { startAgentSummarization } from '../agentSummary.js'
|
||||||
type AgentSummaryDependencies,
|
|
||||||
startAgentSummarization,
|
|
||||||
} from '../agentSummary.js'
|
|
||||||
|
|
||||||
const transcriptMessages = [
|
const transcriptMessages = [
|
||||||
{ type: 'user', message: { content: 'start' }, uuid: 'u1' },
|
{ type: 'user', message: { content: 'start' }, uuid: 'u1' },
|
||||||
@@ -30,16 +27,17 @@ describe('startAgentSummarization', () => {
|
|||||||
let forkCalls: ForkCall[]
|
let forkCalls: ForkCall[]
|
||||||
let updateCalls: Array<{ taskId: string; summary: string }>
|
let updateCalls: Array<{ taskId: string; summary: string }>
|
||||||
let transcriptMessagesForTest: Message[]
|
let transcriptMessagesForTest: Message[]
|
||||||
let debugLogs: string[]
|
|
||||||
let loggedErrors: Error[]
|
|
||||||
let clearedHandles: unknown[]
|
|
||||||
let scheduledCount: number
|
|
||||||
let lastTimerHandle: unknown
|
|
||||||
|
|
||||||
function startTestSummarization(
|
beforeEach(() => {
|
||||||
dependencies: AgentSummaryDependencies = {},
|
forkCalls = []
|
||||||
): { stop: () => void } {
|
updateCalls = []
|
||||||
return startAgentSummarization(
|
scheduled = undefined
|
||||||
|
handle = undefined
|
||||||
|
transcriptMessagesForTest = transcriptMessages
|
||||||
|
})
|
||||||
|
|
||||||
|
test('summarizes bounded transcript once and skips unchanged fingerprints', async () => {
|
||||||
|
handle = startAgentSummarization(
|
||||||
'task-1',
|
'task-1',
|
||||||
asAgentId('a0000000000000000'),
|
asAgentId('a0000000000000000'),
|
||||||
{
|
{
|
||||||
@@ -50,22 +48,14 @@ describe('startAgentSummarization', () => {
|
|||||||
} as unknown as CacheSafeParams,
|
} as unknown as CacheSafeParams,
|
||||||
() => undefined,
|
() => undefined,
|
||||||
{
|
{
|
||||||
clearTimeout: ((timeoutId: unknown) => {
|
clearTimeout: () => undefined,
|
||||||
clearedHandles.push(timeoutId)
|
|
||||||
}) as typeof clearTimeout,
|
|
||||||
getAgentTranscript: async () => ({
|
getAgentTranscript: async () => ({
|
||||||
messages: transcriptMessagesForTest,
|
messages: transcriptMessagesForTest,
|
||||||
contentReplacements: [],
|
contentReplacements: [],
|
||||||
}),
|
}),
|
||||||
isPoorModeActive: () => false,
|
isPoorModeActive: () => false,
|
||||||
logError: error => {
|
logError: () => undefined,
|
||||||
loggedErrors.push(
|
logForDebugging: () => undefined,
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
logForDebugging: message => {
|
|
||||||
debugLogs.push(message)
|
|
||||||
},
|
|
||||||
runForkedAgent: async (args: ForkCall) => {
|
runForkedAgent: async (args: ForkCall) => {
|
||||||
forkCalls.push(args)
|
forkCalls.push(args)
|
||||||
return {
|
return {
|
||||||
@@ -83,38 +73,14 @@ describe('startAgentSummarization', () => {
|
|||||||
if (typeof callback !== 'function') {
|
if (typeof callback !== 'function') {
|
||||||
throw new Error('Expected timer callback')
|
throw new Error('Expected timer callback')
|
||||||
}
|
}
|
||||||
scheduledCount += 1
|
|
||||||
scheduled = callback as () => void | Promise<void>
|
scheduled = callback as () => void | Promise<void>
|
||||||
lastTimerHandle = { id: scheduledCount }
|
return 1 as unknown as ReturnType<typeof setTimeout>
|
||||||
return lastTimerHandle as ReturnType<typeof setTimeout>
|
|
||||||
}) as unknown as typeof setTimeout,
|
}) as unknown as typeof setTimeout,
|
||||||
updateAgentSummary: (taskId: string, summary: string) => {
|
updateAgentSummary: (taskId: string, summary: string) => {
|
||||||
updateCalls.push({ taskId, summary })
|
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')
|
expect(typeof scheduled).toBe('function')
|
||||||
await scheduled!()
|
await scheduled!()
|
||||||
@@ -138,91 +104,49 @@ describe('startAgentSummarization', () => {
|
|||||||
|
|
||||||
expect(forkCalls).toHaveLength(1)
|
expect(forkCalls).toHaveLength(1)
|
||||||
expect(updateCalls).toHaveLength(1)
|
expect(updateCalls).toHaveLength(1)
|
||||||
expect(loggedErrors).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('skips summarization when filtering leaves too little bounded context', async () => {
|
test('skips summarization when bounded context is too small', async () => {
|
||||||
transcriptMessagesForTest = [
|
transcriptMessagesForTest = transcriptMessages.slice(0, 2)
|
||||||
{ type: 'user', message: { content: 'start' }, uuid: 'u1' },
|
|
||||||
|
handle = startAgentSummarization(
|
||||||
|
'task-1',
|
||||||
|
asAgentId('a0000000000000000'),
|
||||||
{
|
{
|
||||||
type: 'assistant',
|
forkContextMessages: transcriptMessages,
|
||||||
uuid: 'a1',
|
model: 'claude-test',
|
||||||
message: {
|
} as unknown as CacheSafeParams,
|
||||||
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
|
() => undefined,
|
||||||
|
{
|
||||||
|
clearTimeout: () => undefined,
|
||||||
|
getAgentTranscript: async () => ({
|
||||||
|
messages: transcriptMessagesForTest,
|
||||||
|
contentReplacements: [],
|
||||||
|
}),
|
||||||
|
isPoorModeActive: () => false,
|
||||||
|
logError: () => undefined,
|
||||||
|
logForDebugging: () => undefined,
|
||||||
|
runForkedAgent: async (args: ForkCall) => {
|
||||||
|
forkCalls.push(args)
|
||||||
|
return { messages: [] } as unknown as ForkedAgentResult
|
||||||
|
},
|
||||||
|
setTimeout: ((callback: TimerHandler) => {
|
||||||
|
if (typeof callback !== 'function') {
|
||||||
|
throw new Error('Expected timer callback')
|
||||||
|
}
|
||||||
|
scheduled = callback as () => void | Promise<void>
|
||||||
|
return 1 as unknown as ReturnType<typeof setTimeout>
|
||||||
|
}) as unknown as typeof setTimeout,
|
||||||
|
updateAgentSummary: (taskId: string, summary: string) => {
|
||||||
|
updateCalls.push({ taskId, summary })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ 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')
|
expect(typeof scheduled).toBe('function')
|
||||||
await scheduled!()
|
await scheduled!()
|
||||||
|
|
||||||
expect(forkCalls).toEqual([])
|
expect(forkCalls).toEqual([])
|
||||||
expect(updateCalls).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])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -141,13 +141,6 @@ describe('getSummaryContextFingerprint', () => {
|
|||||||
expect(estimateMessageChars(message)).toBeGreaterThan(0)
|
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', () => {
|
test('returns null for an empty transcript', () => {
|
||||||
expect(getSummaryContextFingerprint([])).toBeNull()
|
expect(getSummaryContextFingerprint([])).toBeNull()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ function buildAgentContent(params: {
|
|||||||
'',
|
'',
|
||||||
instincts
|
instincts
|
||||||
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
|
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
|
||||||
.slice(0, 20)
|
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|||||||
@@ -35,18 +35,15 @@ export function createInstinct(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_EVIDENCE_ENTRIES = 10
|
|
||||||
|
|
||||||
export function normalizeInstinct(instinct: StoredInstinct): StoredInstinct {
|
export function normalizeInstinct(instinct: StoredInstinct): StoredInstinct {
|
||||||
const uniqueEvidence = Array.from(new Set(instinct.evidence.filter(Boolean)))
|
|
||||||
return {
|
return {
|
||||||
...instinct,
|
...instinct,
|
||||||
id: instinct.id || buildInstinctId(instinct.trigger, instinct.action),
|
id: instinct.id || buildInstinctId(instinct.trigger, instinct.action),
|
||||||
confidence: clampConfidence(instinct.confidence),
|
confidence: clampConfidence(instinct.confidence),
|
||||||
evidence: uniqueEvidence.slice(-MAX_EVIDENCE_ENTRIES),
|
evidence: Array.from(new Set(instinct.evidence.filter(Boolean))),
|
||||||
evidenceOutcome: instinct.evidenceOutcome,
|
evidenceOutcome: instinct.evidenceOutcome,
|
||||||
observationIds: instinct.observationIds
|
observationIds: instinct.observationIds
|
||||||
? Array.from(new Set(instinct.observationIds)).slice(-20)
|
? Array.from(new Set(instinct.observationIds))
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ import {
|
|||||||
import type { LearnedSkillDraft, SkillLearningScope } from './types.js'
|
import type { LearnedSkillDraft, SkillLearningScope } from './types.js'
|
||||||
|
|
||||||
export const DUPLICATE_SKILL_OVERLAP_THRESHOLD = 0.8
|
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 = {
|
export type SkillGeneratorOptions = {
|
||||||
cwd?: string
|
cwd?: string
|
||||||
@@ -104,41 +101,20 @@ export async function appendInstinctEvidenceToSkill(
|
|||||||
const existing = await readFile(target.path, 'utf8').catch(
|
const existing = await readFile(target.path, 'utf8').catch(
|
||||||
() => target.content,
|
() => 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 now = new Date().toISOString()
|
||||||
const block = [
|
const block = [
|
||||||
'',
|
'',
|
||||||
`## Learned evidence (${now})`,
|
`## Learned evidence (${now})`,
|
||||||
'',
|
'',
|
||||||
...evidenceLines,
|
...instincts.flatMap(instinct =>
|
||||||
|
instinct.evidence.map(evidence => `- ${evidence}`),
|
||||||
|
),
|
||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const merged = existing.endsWith('\n')
|
const merged = existing.endsWith('\n')
|
||||||
? existing + block
|
? existing + block
|
||||||
: `${existing}\n${block}`
|
: `${existing}\n${block}`
|
||||||
|
await writeFile(target.path, merged, 'utf8')
|
||||||
// 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')
|
|
||||||
clearSkillIndexCache()
|
clearSkillIndexCache()
|
||||||
return target.path
|
return target.path
|
||||||
}
|
}
|
||||||
@@ -215,7 +191,6 @@ function buildSkillContent(params: {
|
|||||||
'',
|
'',
|
||||||
instincts
|
instincts
|
||||||
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
|
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
|
||||||
.slice(0, MAX_EVIDENCE_LINES_IN_SKILL)
|
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
import { mkdtempSync } from 'node:fs'
|
import { mkdtempSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import type { Message } from 'src/types/message.js'
|
import type { Message } from 'src/types/message.js'
|
||||||
import { getErrnoCode } from 'src/utils/errors.js'
|
|
||||||
import {
|
import {
|
||||||
compactMailboxMessages,
|
compactMailboxMessages,
|
||||||
getLastPeerDmSummary,
|
getLastPeerDmSummary,
|
||||||
@@ -172,17 +171,6 @@ describe('compactMailboxMessages', () => {
|
|||||||
|
|
||||||
expect(compacted).toEqual([])
|
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', () => {
|
describe('teammate mailbox retention', () => {
|
||||||
@@ -343,36 +331,6 @@ describe('teammate mailbox retention', () => {
|
|||||||
expect(await readFile(inboxPath, 'utf-8')).toBe('{not-json')
|
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 () => {
|
test('readMailbox fails closed on corrupt mailbox content', async () => {
|
||||||
const inboxPath = getInboxPath('worker', 'alpha')
|
const inboxPath = getInboxPath('worker', 'alpha')
|
||||||
await mkdir(dirname(inboxPath), { recursive: true })
|
await mkdir(dirname(inboxPath), { recursive: true })
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
writeFile,
|
writeFile,
|
||||||
} from 'node:fs/promises'
|
} from 'node:fs/promises'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { createConnection, createServer, type Socket } from 'node:net'
|
import { createConnection, createServer } from 'node:net'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import {
|
import {
|
||||||
@@ -217,159 +217,6 @@ describe('UDS inbox retention', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
test('sendUdsMessage fails closed before connecting without an auth token', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
sendUdsMessage(socketPath('no-auth-token'), { type: 'text', data: 'x' }),
|
sendUdsMessage(socketPath('no-auth-token'), { type: 'text', data: 'x' }),
|
||||||
|
|||||||
@@ -97,28 +97,6 @@ describe('attachUdsResponseReader', () => {
|
|||||||
expect(socket.ended).toBe(true)
|
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', () => {
|
test('rejects receiver error frames', () => {
|
||||||
const socket = new FakeSocket()
|
const socket = new FakeSocket()
|
||||||
let settledError: Error | undefined
|
let settledError: Error | undefined
|
||||||
@@ -138,31 +116,6 @@ describe('attachUdsResponseReader', () => {
|
|||||||
expect(socket.destroyed).toBe(true)
|
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', () => {
|
test('uses custom socket error formatting', () => {
|
||||||
const socket = new FakeSocket()
|
const socket = new FakeSocket()
|
||||||
let settledError: Error | undefined
|
let settledError: Error | undefined
|
||||||
|
|||||||
@@ -132,11 +132,10 @@ export function truncateToWidthNoEllipsis(
|
|||||||
* @returns The truncated string with ellipsis if needed
|
* @returns The truncated string with ellipsis if needed
|
||||||
*/
|
*/
|
||||||
export function truncate(
|
export function truncate(
|
||||||
str: string | undefined | null,
|
str: string,
|
||||||
maxWidth: number,
|
maxWidth: number,
|
||||||
singleLine: boolean = false,
|
singleLine: boolean = false,
|
||||||
): string {
|
): string {
|
||||||
if (str == null) return ''
|
|
||||||
let result = str
|
let result = str
|
||||||
|
|
||||||
// If singleLine is true, truncate at first newline
|
// If singleLine is true, truncate at first newline
|
||||||
|
|||||||
@@ -36,19 +36,6 @@ export type PeerSession = {
|
|||||||
alive: boolean
|
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
|
// Session directory
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -206,7 +193,6 @@ export async function isPeerAlive(
|
|||||||
export async function sendToUdsSocket(
|
export async function sendToUdsSocket(
|
||||||
targetSocketPath: string,
|
targetSocketPath: string,
|
||||||
message: string | Record<string, unknown>,
|
message: string | Record<string, unknown>,
|
||||||
timeoutMs = 5000,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { parseUdsTarget } = await import('./udsMessaging.js')
|
const { parseUdsTarget } = await import('./udsMessaging.js')
|
||||||
const target = parseUdsTarget(targetSocketPath)
|
const target = parseUdsTarget(targetSocketPath)
|
||||||
@@ -251,63 +237,29 @@ export async function sendToUdsSocket(
|
|||||||
maxFrameBytes: MAX_UDS_FRAME_BYTES,
|
maxFrameBytes: MAX_UDS_FRAME_BYTES,
|
||||||
onSettled: finish,
|
onSettled: finish,
|
||||||
formatSocketError: err =>
|
formatSocketError: err =>
|
||||||
new UdsPeerConnectionError(target.socketPath, err),
|
new Error(
|
||||||
})
|
`Failed to connect to peer at ${target.socketPath}: ${errorMessage(err)}`,
|
||||||
conn.setTimeout(timeoutMs, () => {
|
|
||||||
finish(
|
|
||||||
new UdsPeerConnectionError(
|
|
||||||
target.socketPath,
|
|
||||||
new Error('Connection timed out'),
|
|
||||||
),
|
),
|
||||||
)
|
})
|
||||||
|
conn.setTimeout(5000, () => {
|
||||||
|
finish(new Error('Connection timed out'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to a peer and return the raw socket for bidirectional communication.
|
* Connect to a peer and return the raw socket for bidirectional communication.
|
||||||
* The caller owns the post-connect lifecycle through onSocketError, which is
|
* The caller is responsible for managing the connection lifecycle.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function connectToPeer(
|
export function connectToPeer(socketPath: string): Promise<Socket> {
|
||||||
socketPath: string,
|
|
||||||
onSocketError: (error: Error) => void,
|
|
||||||
timeoutMs = 5000,
|
|
||||||
): Promise<Socket> {
|
|
||||||
return new Promise<Socket>((resolve, reject) => {
|
return new Promise<Socket>((resolve, reject) => {
|
||||||
const conn = createConnection(socketPath)
|
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)
|
|
||||||
resolve(conn)
|
resolve(conn)
|
||||||
})
|
})
|
||||||
conn.on('error', fail)
|
conn.on('error', reject)
|
||||||
|
conn.setTimeout(5000, () => {
|
||||||
|
conn.destroy(new Error('Connection timed out'))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -557,26 +557,7 @@ export async function startUdsMessaging(
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
// Restrict socket permissions to owner-only. On macOS with
|
await chmod(path, 0o600)
|
||||||
// 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.off('error', rejectBeforeListen)
|
||||||
srv.on('error', logRuntimeError)
|
srv.on('error', logRuntimeError)
|
||||||
|
|||||||
Reference in New Issue
Block a user