feat: 添加服务层增强与零散改进

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
unraid
2026-04-22 22:38:10 +08:00
parent 2247026bd5
commit c7e1c50b86
23 changed files with 861 additions and 100 deletions

View File

@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
} from '../../bootstrap/state'
import { getTaskListId } from '../../utils/tasks'
import { getTeamFilePath } from '../../utils/swarm/teamHelpers'
import { initializeAssistantTeam } from '../index'
let tempDir = ''
let previousConfigDir: string | undefined
beforeEach(() => {
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
tempDir = join(
tmpdir(),
`assistant-team-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
process.env.CLAUDE_CONFIG_DIR = join(tempDir, 'config')
resetStateForTests()
setOriginalCwd(tempDir)
setCwdState(tempDir)
})
afterEach(async () => {
resetStateForTests()
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
await rm(tempDir, { recursive: true, force: true })
})
describe('initializeAssistantTeam', () => {
test('creates a session-scoped in-process team context and task list', async () => {
const context = await initializeAssistantTeam()
expect(context).toBeDefined()
const teamContext = context!
expect(teamContext.teamName).toStartWith('assistant-')
expect(teamContext.isLeader).toBe(true)
expect(teamContext.selfAgentName).toBe('team-lead')
expect(
teamContext.teammates[teamContext.leadAgentId]?.tmuxSessionName,
).toBe('in-process')
expect(getTaskListId()).toBe(teamContext.teamName)
const raw = await readFile(getTeamFilePath(teamContext.teamName), 'utf-8')
const teamFile = JSON.parse(raw)
expect(teamFile.leadAgentId).toBe(teamContext.leadAgentId)
expect(teamFile.members[0].backendType).toBe('in-process')
expect(teamFile.members[0].agentType).toBe('assistant')
})
})

View File

@@ -1,7 +1,24 @@
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { getKairosActive } from '../bootstrap/state.js' import { getKairosActive, getSessionId } from '../bootstrap/state.js'
import type { AppState } from '../state/AppState.js'
import { formatAgentId } from '../utils/agentId.js'
import { getCwd } from '../utils/cwd.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js'
import {
getTeamFilePath,
registerTeamForSessionCleanup,
sanitizeName,
writeTeamFileAsync,
type TeamFile,
} from '../utils/swarm/teamHelpers.js'
import { assignTeammateColor } from '../utils/swarm/teammateLayoutManager.js'
import {
ensureTasksDir,
resetTaskList,
setLeaderTeamName,
} from '../utils/tasks.js'
let _assistantForced = false let _assistantForced = false
@@ -29,13 +46,67 @@ export function isAssistantForced(): boolean {
* Pre-create an in-process team so Agent(name) can spawn teammates * Pre-create an in-process team so Agent(name) can spawn teammates
* without TeamCreate. * without TeamCreate.
* *
* Phase 1: returns undefined so main.tsx's `assistantTeamContext ?? computeInitialTeamContext()` * Creates a session-scoped assistant team file and returns a full team
* correctly falls back. Returning {} would bypass the ?? operator since {} is truthy. * context object matching AppState.teamContext.
*
* Phase 2: should return a full team context object matching AppState.teamContext shape.
*/ */
export async function initializeAssistantTeam(): Promise<undefined> { export async function initializeAssistantTeam(): Promise<
return undefined AppState['teamContext']
> {
const sessionId = getSessionId()
const teamName = sanitizeName(`assistant-${sessionId.slice(0, 8)}`)
const leadAgentId = formatAgentId(TEAM_LEAD_NAME, teamName)
const teamFilePath = getTeamFilePath(teamName)
const now = Date.now()
const cwd = getCwd()
const color = assignTeammateColor(leadAgentId)
const teamFile: TeamFile = {
name: teamName,
description: 'Assistant mode in-process team',
createdAt: now,
leadAgentId,
leadSessionId: sessionId,
members: [
{
agentId: leadAgentId,
name: TEAM_LEAD_NAME,
agentType: 'assistant',
color,
joinedAt: now,
tmuxPaneId: '',
cwd,
subscriptions: [],
backendType: 'in-process',
},
],
}
await writeTeamFileAsync(teamName, teamFile)
registerTeamForSessionCleanup(teamName)
await resetTaskList(teamName)
await ensureTasksDir(teamName)
setLeaderTeamName(teamName)
return {
teamName,
teamFilePath,
leadAgentId,
selfAgentId: leadAgentId,
selfAgentName: TEAM_LEAD_NAME,
isLeader: true,
selfAgentColor: color,
teammates: {
[leadAgentId]: {
name: TEAM_LEAD_NAME,
agentType: 'assistant',
color,
tmuxSessionName: 'in-process',
tmuxPaneId: 'leader',
cwd,
spawnedAt: now,
},
},
}
} }
/** /**

View File

@@ -1963,7 +1963,6 @@ NOTES
- You must be logged in with a Claude account that has a subscription - You must be logged in with a Claude account that has a subscription
- Run \`claude\` first in the directory to accept the workspace trust dialog - Run \`claude\` first in the directory to accept the workspace trust dialog
${serverNote}` ${serverNote}`
// biome-ignore lint/suspicious/noConsole: intentional help output
console.log(help) console.log(help)
} }
@@ -2002,7 +2001,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
return return
} }
if (parsed.error) { if (parsed.error) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error(`Error: ${parsed.error}`) console.error(`Error: ${parsed.error}`)
// eslint-disable-next-line custom-rules/no-process-exit // eslint-disable-next-line custom-rules/no-process-exit
process.exit(1) process.exit(1)
@@ -2041,7 +2039,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
const { PERMISSION_MODES } = await import('../types/permissions.js') const { PERMISSION_MODES } = await import('../types/permissions.js')
const valid: readonly string[] = PERMISSION_MODES const valid: readonly string[] = PERMISSION_MODES
if (!valid.includes(permissionMode)) { if (!valid.includes(permissionMode)) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`, `Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`,
) )
@@ -2084,7 +2081,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
Promise.all([shutdown1PEventLogging(), shutdownDatadog()]), Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
sleep(500, undefined, { unref: true }), sleep(500, undefined, { unref: true }),
]).catch(() => {}) ]).catch(() => {})
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
'Error: Multi-session Remote Control is not enabled for your account yet.', 'Error: Multi-session Remote Control is not enabled for your account yet.',
) )
@@ -2101,7 +2097,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
// The bridge bypasses main.tsx (which renders the interactive TrustDialog via showSetupScreens), // The bridge bypasses main.tsx (which renders the interactive TrustDialog via showSetupScreens),
// so we must verify trust was previously established by a normal `claude` session. // so we must verify trust was previously established by a normal `claude` session.
if (!checkHasTrustDialogAccepted()) { if (!checkHasTrustDialogAccepted()) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
`Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`, `Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`,
) )
@@ -2118,7 +2113,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
const bridgeToken = getBridgeAccessToken() const bridgeToken = getBridgeAccessToken()
if (!bridgeToken) { if (!bridgeToken) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(BRIDGE_LOGIN_ERROR) console.error(BRIDGE_LOGIN_ERROR)
// eslint-disable-next-line custom-rules/no-process-exit // eslint-disable-next-line custom-rules/no-process-exit
process.exit(1) process.exit(1)
@@ -2137,7 +2131,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
}) })
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
'\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n', '\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n',
) )
@@ -2169,7 +2162,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
) )
const found = await readBridgePointerAcrossWorktrees(dir) const found = await readBridgePointerAcrossWorktrees(dir)
if (!found) { if (!found) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`, `Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`,
) )
@@ -2180,7 +2172,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
const ageMin = Math.round(pointer.ageMs / 60_000) const ageMin = Math.round(pointer.ageMs / 60_000)
const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h` const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`
const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : '' const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : ''
// biome-ignore lint/suspicious/noConsole: intentional info output
console.error( console.error(
`Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`, `Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`,
) )
@@ -2201,7 +2192,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
!baseUrl.includes('localhost') && !baseUrl.includes('localhost') &&
!baseUrl.includes('127.0.0.1') !baseUrl.includes('127.0.0.1')
) { ) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.', 'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.',
) )
@@ -2237,7 +2227,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
? getCurrentProjectConfig().remoteControlSpawnMode ? getCurrentProjectConfig().remoteControlSpawnMode
: undefined : undefined
if (savedSpawnMode === 'worktree' && !worktreeAvailable) { if (savedSpawnMode === 'worktree' && !worktreeAvailable) {
// biome-ignore lint/suspicious/noConsole: intentional warning output
console.error( console.error(
'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.', 'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.',
) )
@@ -2264,7 +2253,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
}) })
// biome-ignore lint/suspicious/noConsole: intentional dialog output
console.log( console.log(
`\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` + `\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` +
`Spawn mode for this project:\n` + `Spawn mode for this project:\n` +
@@ -2343,7 +2331,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
// Only reachable via explicit --spawn=worktree (default is same-dir); // Only reachable via explicit --spawn=worktree (default is same-dir);
// saved worktree pref was already guarded above. // saved worktree pref was already guarded above.
if (spawnMode === 'worktree' && !worktreeAvailable) { if (spawnMode === 'worktree' && !worktreeAvailable) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`, `Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`,
) )
@@ -2378,7 +2365,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
try { try {
validateBridgeId(resumeSessionId, 'sessionId') validateBridgeId(resumeSessionId, 'sessionId')
} catch { } catch {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`, `Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`,
) )
@@ -2404,7 +2390,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
const { clearBridgePointer } = await import('./bridgePointer.js') const { clearBridgePointer } = await import('./bridgePointer.js')
await clearBridgePointer(resumePointerDir) await clearBridgePointer(resumePointerDir)
} }
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`, `Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`,
) )
@@ -2416,7 +2401,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
const { clearBridgePointer } = await import('./bridgePointer.js') const { clearBridgePointer } = await import('./bridgePointer.js')
await clearBridgePointer(resumePointerDir) await clearBridgePointer(resumePointerDir)
} }
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`, `Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`,
) )
@@ -2470,7 +2454,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
status: err instanceof BridgeFatalError ? err.status : undefined, status: err instanceof BridgeFatalError ? err.status : undefined,
}) })
// Registration failures are fatal — print a clean message instead of a stack trace. // Registration failures are fatal — print a clean message instead of a stack trace.
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
err instanceof BridgeFatalError && err.status === 404 err instanceof BridgeFatalError && err.status === 404
? 'Remote Control environments are not available for your account.' ? 'Remote Control environments are not available for your account.'
@@ -2495,7 +2478,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
`Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`, `Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`,
), ),
) )
// biome-ignore lint/suspicious/noConsole: intentional warning output
console.warn( console.warn(
`Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`, `Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`,
) )
@@ -2546,7 +2528,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
const { clearBridgePointer } = await import('./bridgePointer.js') const { clearBridgePointer } = await import('./bridgePointer.js')
await clearBridgePointer(resumePointerDir) await clearBridgePointer(resumePointerDir)
} }
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
isFatal isFatal
? `Error: ${errorMessage(err)}` ? `Error: ${errorMessage(err)}`

View File

@@ -17,7 +17,6 @@
/** Write an error message to stderr (if given) and exit with code 1. */ /** Write an error message to stderr (if given) and exit with code 1. */
export function cliError(msg?: string): never { export function cliError(msg?: string): never {
// biome-ignore lint/suspicious/noConsole: centralized CLI error output
if (msg) console.error(msg) if (msg) console.error(msg)
process.exit(1) process.exit(1)
return undefined as never return undefined as never

View File

@@ -59,12 +59,9 @@ export async function agentsHandler(): Promise<void> {
} }
if (lines.length === 0) { if (lines.length === 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('No agents found.') console.log('No agents found.')
} else { } else {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${totalActive} active agents\n`) console.log(`${totalActive} active agents\n`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(lines.join('\n').trimEnd()) console.log(lines.join('\n').trimEnd())
} }
} }

View File

@@ -72,27 +72,21 @@ export function handleMarketplaceError(error: unknown, action: string): never {
function printValidationResult(result: ValidationResult): void { function printValidationResult(result: ValidationResult): void {
if (result.errors.length > 0) { if (result.errors.length > 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
`${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`, `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`,
) )
result.errors.forEach(error => { result.errors.forEach(error => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${error.path}: ${error.message}`) console.log(` ${figures.pointer} ${error.path}: ${error.message}`)
}) })
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
} }
if (result.warnings.length > 0) { if (result.warnings.length > 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
`${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`, `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`,
) )
result.warnings.forEach(warning => { result.warnings.forEach(warning => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`) console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`)
}) })
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
} }
} }
@@ -106,7 +100,6 @@ export async function pluginValidateHandler(
try { try {
const result = await validateManifest(manifestPath) const result = await validateManifest(manifestPath)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`) console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`)
printValidationResult(result) printValidationResult(result)
@@ -120,7 +113,6 @@ export async function pluginValidateHandler(
if (basename(manifestDir) === '.claude-plugin') { if (basename(manifestDir) === '.claude-plugin') {
contentResults = await validatePluginContents(dirname(manifestDir)) contentResults = await validatePluginContents(dirname(manifestDir))
for (const r of contentResults) { for (const r of contentResults) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Validating ${r.fileType}: ${r.filePath}\n`) console.log(`Validating ${r.fileType}: ${r.filePath}\n`)
printValidationResult(r) printValidationResult(r)
} }
@@ -139,13 +131,11 @@ export async function pluginValidateHandler(
: `${figures.tick} Validation passed`, : `${figures.tick} Validation passed`,
) )
} else { } else {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${figures.cross} Validation failed`) console.log(`${figures.cross} Validation failed`)
process.exit(1) process.exit(1)
} }
} catch (error) { } catch (error) {
logError(error) logError(error)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`, `${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
) )
@@ -358,7 +348,6 @@ export async function pluginListHandler(options: {
} }
if (pluginIds.length > 0) { if (pluginIds.length > 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Installed plugins:\n') console.log('Installed plugins:\n')
} }
@@ -383,25 +372,18 @@ export async function pluginListHandler(options: {
const version = installation.version || 'unknown' const version = installation.version || 'unknown'
const scope = installation.scope const scope = installation.scope
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${pluginId}`) console.log(` ${figures.pointer} ${pluginId}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Version: ${version}`) console.log(` Version: ${version}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Scope: ${scope}`) console.log(` Scope: ${scope}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Status: ${status}`) console.log(` Status: ${status}`)
for (const error of pluginErrors) { for (const error of pluginErrors) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Error: ${getPluginErrorMessage(error)}`) console.log(` Error: ${getPluginErrorMessage(error)}`)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
} }
} }
if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) { if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Session-only plugins (--plugin-dir):\n') console.log('Session-only plugins (--plugin-dir):\n')
for (const p of inlinePlugins) { for (const p of inlinePlugins) {
// Same dirName≠manifestName fallback as the JSON path above — error // Same dirName≠manifestName fallback as the JSON path above — error
@@ -413,19 +395,13 @@ export async function pluginListHandler(options: {
pErrors.length > 0 pErrors.length > 0
? `${figures.cross} loaded with errors` ? `${figures.cross} loaded with errors`
: `${figures.tick} loaded` : `${figures.tick} loaded`
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${p.source}`) console.log(` ${figures.pointer} ${p.source}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Version: ${p.manifest.version ?? 'unknown'}`) console.log(` Version: ${p.manifest.version ?? 'unknown'}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Path: ${p.path}`) console.log(` Path: ${p.path}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Status: ${status}`) console.log(` Status: ${status}`)
for (const e of pErrors) { for (const e of pErrors) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Error: ${getPluginErrorMessage(e)}`) console.log(` Error: ${getPluginErrorMessage(e)}`)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
} }
// Path-level failures: no LoadedPlugin object exists. Show them so // Path-level failures: no LoadedPlugin object exists. Show them so
@@ -433,7 +409,6 @@ export async function pluginListHandler(options: {
for (const e of inlineLoadErrors.filter(e => for (const e of inlineLoadErrors.filter(e =>
e.source.startsWith('inline['), e.source.startsWith('inline['),
)) { )) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`, ` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`,
) )
@@ -489,12 +464,10 @@ export async function marketplaceAddHandler(
} }
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Adding marketplace...') console.log('Adding marketplace...')
const { name, alreadyMaterialized, resolvedSource } = const { name, alreadyMaterialized, resolvedSource } =
await addMarketplaceSource(marketplaceSource, message => { await addMarketplaceSource(marketplaceSource, message => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(message) console.log(message)
}) })
@@ -555,33 +528,25 @@ export async function marketplaceListHandler(options: {
cliOk('No marketplaces configured') cliOk('No marketplaces configured')
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Configured marketplaces:\n') console.log('Configured marketplaces:\n')
names.forEach(name => { names.forEach(name => {
const marketplace = config[name] const marketplace = config[name]
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${name}`) console.log(` ${figures.pointer} ${name}`)
if (marketplace?.source) { if (marketplace?.source) {
const src = marketplace.source const src = marketplace.source
if (src.source === 'github') { if (src.source === 'github') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: GitHub (${src.repo})`) console.log(` Source: GitHub (${src.repo})`)
} else if (src.source === 'git') { } else if (src.source === 'git') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: Git (${src.url})`) console.log(` Source: Git (${src.url})`)
} else if (src.source === 'url') { } else if (src.source === 'url') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: URL (${src.url})`) console.log(` Source: URL (${src.url})`)
} else if (src.source === 'directory') { } else if (src.source === 'directory') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: Directory (${src.path})`) console.log(` Source: Directory (${src.path})`)
} else if (src.source === 'file') { } else if (src.source === 'file') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: File (${src.path})`) console.log(` Source: File (${src.path})`)
} }
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
}) })
@@ -620,11 +585,9 @@ export async function marketplaceUpdateHandler(
if (options.cowork) setUseCoworkPlugins(true) if (options.cowork) setUseCoworkPlugins(true)
try { try {
if (name) { if (name) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Updating marketplace: ${name}...`) console.log(`Updating marketplace: ${name}...`)
await refreshMarketplace(name, message => { await refreshMarketplace(name, message => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(message) console.log(message)
}) })
@@ -644,7 +607,6 @@ export async function marketplaceUpdateHandler(
cliOk('No marketplaces configured') cliOk('No marketplaces configured')
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Updating ${marketplaceNames.length} marketplace(s)...`) console.log(`Updating ${marketplaceNames.length} marketplace(s)...`)
await refreshAllMarketplaces() await refreshAllMarketplaces()

View File

@@ -462,7 +462,6 @@ export class StructuredIO {
} }
return message return message
} catch (error) { } catch (error) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`Error parsing streaming input line: ${line}: ${error}`) console.error(`Error parsing streaming input line: ${line}: ${error}`)
// eslint-disable-next-line custom-rules/no-process-exit // eslint-disable-next-line custom-rules/no-process-exit
process.exit(1) process.exit(1)
@@ -687,7 +686,6 @@ export class StructuredIO {
) )
return result return result
} catch (error) { } catch (error) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`Error in hook callback ${callbackId}:`, error) console.error(`Error in hook callback ${callbackId}:`, error)
return {} return {}
} }
@@ -781,7 +779,6 @@ export class StructuredIO {
} }
function exitWithMessage(message: string): never { function exitWithMessage(message: string): never {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(message) console.error(message)
// eslint-disable-next-line custom-rules/no-process-exit // eslint-disable-next-line custom-rules/no-process-exit
process.exit(1) process.exit(1)

View File

@@ -67,7 +67,7 @@ export function parseReferences(
const matches = [...input.matchAll(referencePattern)] const matches = [...input.matchAll(referencePattern)]
return matches return matches
.map(match => ({ .map(match => ({
id: parseInt(match[2] || '0'), id: parseInt(match[2] || '0', 10),
match: match[0], match: match[0],
index: match.index, index: match.index,
})) }))

View File

@@ -19,7 +19,7 @@ const MIGRATIONS: ((c: GlobalConfig) => Notification | undefined)[] = [
} }
}, },
// Opus Pro → default, or pinned 4.0/4.1 → opus alias. Both land on the // Opus Pro → default, or pinned 4.0/4.1 → opus alias. Both land on the
// current Opus default (4.6 for 1P). // current Opus default (4.7 for 1P).
c => { c => {
const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp) const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp)
const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp
@@ -27,8 +27,8 @@ const MIGRATIONS: ((c: GlobalConfig) => Notification | undefined)[] = [
return { return {
key: 'opus-pro-update', key: 'opus-pro-update',
text: isLegacyRemap text: isLegacyRemap
? 'Model updated to Opus 4.6 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' ? 'Model updated to Opus 4.7 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out'
: 'Model updated to Opus 4.6', : 'Model updated to Opus 4.7',
color: 'suggestion', color: 'suggestion',
priority: 'high', priority: 'high',
timeoutMs: isLegacyRemap ? 8000 : 3000, timeoutMs: isLegacyRemap ? 8000 : 3000,

View File

@@ -97,16 +97,13 @@ export function useIssueFlagBanner(
return false return false
} }
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
const lastTriggeredAtRef = useRef(0) const lastTriggeredAtRef = useRef(0)
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
const activeForSubmitRef = useRef(-1) const activeForSubmitRef = useRef(-1)
// Memoize the O(messages) scans. This hook runs on every REPL render // Memoize the O(messages) scans. This hook runs on every REPL render
// (including every keystroke), but messages is stable during typing. // (including every keystroke), but messages is stable during typing.
// isSessionContainerCompatible walks all messages + regex-tests each // isSessionContainerCompatible walks all messages + regex-tests each
// bash command — by far the heaviest work here. // bash command — by far the heaviest work here.
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
const shouldTrigger = useMemo( const shouldTrigger = useMemo(
() => isSessionContainerCompatible(messages) && hasFrictionSignal(messages), () => isSessionContainerCompatible(messages) && hasFrictionSignal(messages),
[messages], [messages],

View File

@@ -24,6 +24,7 @@ import type { ImageDimensions } from '../utils/imageResizer.js'
import { isModifierPressed, prewarmModifiers } from '../utils/modifiers.js' import { isModifierPressed, prewarmModifiers } from '../utils/modifiers.js'
import { useDoublePress } from './useDoublePress.js' import { useDoublePress } from './useDoublePress.js'
// biome-ignore lint/suspicious/noConfusingVoidType: void is the correct return type for cursor handlers that return nothing
type MaybeCursor = void | Cursor type MaybeCursor = void | Cursor
type InputHandler = (input: string) => MaybeCursor type InputHandler = (input: string) => MaybeCursor
type InputMapper = (input: string) => MaybeCursor type InputMapper = (input: string) => MaybeCursor

View File

@@ -584,7 +584,6 @@ export function useTypeahead({
const debouncedFetchSlackChannels = useDebounceCallback(fetchSlackChannels, 150); const debouncedFetchSlackChannels = useDebounceCallback(fetchSlackChannels, 150);
// Handle immediate suggestion logic (cheap operations) // Handle immediate suggestion logic (cheap operations)
// biome-ignore lint/correctness/useExhaustiveDependencies: store is a stable context ref, read imperatively at call-time
const updateSuggestions = useCallback( const updateSuggestions = useCallback(
async (value: string, inputCursorOffset?: number): Promise<void> => { async (value: string, inputCursorOffset?: number): Promise<void> => {
// Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset)

View File

@@ -13,7 +13,7 @@ import {
/** /**
* Migrate first-party users off explicit Opus 4.0/4.1 model strings. * Migrate first-party users off explicit Opus 4.0/4.1 model strings.
* *
* The 'opus' alias already resolves to Opus 4.6 for 1P, so anyone still * The 'opus' alias already resolves to Opus 4.7 for 1P, so anyone still
* on an explicit 4.0/4.1 string pinned it in settings before 4.5 launched. * on an explicit 4.0/4.1 string pinned it in settings before 4.5 launched.
* parseUserSpecifiedModel now silently remaps these at runtime anyway — * parseUserSpecifiedModel now silently remaps these at runtime anyway —
* this migration cleans up the settings file so /model shows the right * this migration cleans up the settings file so /model shows the right

View File

@@ -48,6 +48,7 @@ export class FileIndex {
private topLevelCache: SearchResult[] | null = null private topLevelCache: SearchResult[] | null = null
// During async build, tracks how many paths have bitmap/lowerPath filled. // During async build, tracks how many paths have bitmap/lowerPath filled.
// search() uses this to search the ready prefix while build continues. // search() uses this to search the ready prefix while build continues.
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: used via destructuring in search()
private readyCount = 0 private readyCount = 0
/** /**

View File

@@ -111,6 +111,7 @@ function isDefined(n: number): boolean {
// NaN-safe equality for layout-cache input comparison // NaN-safe equality for layout-cache input comparison
function sameFloat(a: number, b: number): boolean { function sameFloat(a: number, b: number): boolean {
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN detection (a !== a is true only for NaN)
return a === b || (a !== a && b !== b) return a === b || (a !== a && b !== b)
} }
@@ -2372,12 +2373,14 @@ function boundAxis(
if (v > maxV.value) v = maxV.value if (v > maxV.value) v = maxV.value
} else if (maxU === 2) { } else if (maxU === 2) {
const m = (maxV.value * owner) / 100 const m = (maxV.value * owner) / 100
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN guard (m === m is false only for NaN)
if (m === m && v > m) v = m if (m === m && v > m) v = m
} }
if (minU === 1) { if (minU === 1) {
if (v < minV.value) v = minV.value if (v < minV.value) v = minV.value
} else if (minU === 2) { } else if (minU === 2) {
const m = (minV.value * owner) / 100 const m = (minV.value * owner) / 100
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN guard (m === m is false only for NaN)
if (m === m && v < m) v = m if (m === m && v < m) v = m
} }
return v return v

View File

@@ -331,6 +331,7 @@ export function initialize1PEventLogging(): void {
parseInt( parseInt(
process.env.OTEL_LOGS_EXPORT_INTERVAL || process.env.OTEL_LOGS_EXPORT_INTERVAL ||
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(), DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
10,
) )
const maxExportBatchSize = const maxExportBatchSize =

View File

@@ -470,6 +470,10 @@ const LOCAL_GATE_DEFAULTS: Record<string, unknown> = {
tengu_kairos_cron_durable: true, // Persistent cron tasks tengu_kairos_cron_durable: true, // Persistent cron tasks
tengu_attribution_header: true, // API request attribution header tengu_attribution_header: true, // API request attribution header
tengu_slate_prism: true, // Agent progress summaries tengu_slate_prism: true, // Agent progress summaries
// ── Ultrareview (cloud code review via CCR) ─────────────────────
tengu_review_bughunter_config: { enabled: true }, // /ultrareview command visibility
tengu_ccr_bundle_seed_enabled: true, // Bundle seed: skip GitHub App check for branch mode
} }
/** /**

View File

@@ -0,0 +1,702 @@
import { mock, describe, test, expect, beforeEach } from 'bun:test'
// Mock @langfuse/otel before any imports
const mockForceFlush = mock(() => Promise.resolve())
const mockShutdown = mock(() => Promise.resolve())
mock.module('@langfuse/otel', () => ({
LangfuseSpanProcessor: class MockLangfuseSpanProcessor {
forceFlush = mockForceFlush
shutdown = mockShutdown
onStart = mock(() => {})
onEnd = mock(() => {})
},
}))
// Mock @opentelemetry/sdk-trace-base
mock.module('@opentelemetry/sdk-trace-base', () => ({
BasicTracerProvider: class MockBasicTracerProvider {
constructor(_opts?: unknown) {}
},
}))
// Mock @langfuse/tracing
const mockChildUpdate = mock(() => {})
const mockChildEnd = mock(() => {})
const mockRootUpdate = mock(() => {})
const mockRootEnd = mock(() => {})
// Mock LangfuseOtelSpanAttributes (re-exported from @langfuse/core)
const mockLangfuseOtelSpanAttributes: Record<string, string> = {
TRACE_SESSION_ID: 'session.id',
TRACE_USER_ID: 'user.id',
OBSERVATION_TYPE: 'observation.type',
OBSERVATION_INPUT: 'observation.input',
OBSERVATION_OUTPUT: 'observation.output',
OBSERVATION_MODEL: 'observation.model',
OBSERVATION_COMPLETION_START_TIME: 'observation.completionStartTime',
OBSERVATION_USAGE_DETAILS: 'observation.usageDetails',
}
const mockSpanContext = {
traceId: 'test-trace-id',
spanId: 'test-span-id',
traceFlags: 1,
}
const mockSetAttribute = mock(() => {})
// Child observation mock (returned by startObservation for tools/generations)
const mockStartObservation = mock(() => ({
id: 'test-span-id',
traceId: 'test-trace-id',
type: 'span',
otelSpan: {
spanContext: () => mockSpanContext,
setAttribute: mockSetAttribute,
},
update: mockRootUpdate,
end: mockRootEnd,
}))
const mockSetLangfuseTracerProvider = mock(() => {})
mock.module('@langfuse/tracing', () => ({
startObservation: mockStartObservation,
LangfuseOtelSpanAttributes: mockLangfuseOtelSpanAttributes,
propagateAttributes: mock((_params: unknown, fn?: () => void) => fn?.()),
setLangfuseTracerProvider: mockSetLangfuseTracerProvider,
}))
// Mock debug logger
mock.module('src/utils/debug.js', () => ({
logForDebugging: mock(() => {}),
logAntError: mock(() => {}),
isDebugToStdErr: () => false,
isDebugMode: () => false,
getDebugLogPath: () => '/tmp/debug.log',
}))
// Mock user module to avoid heavy dependency chain (execa, config, cwd, env, etc.)
mock.module('src/utils/user.js', () => ({
getCoreUserData: () => ({
email: 'test@example.com',
deviceId: 'test-device',
}),
getUserDataForLogging: () => ({}),
}))
describe('Langfuse integration', () => {
beforeEach(() => {
// Reset env
process.env.HOME = '/Users/testuser'
delete process.env.LANGFUSE_PUBLIC_KEY
delete process.env.LANGFUSE_SECRET_KEY
delete process.env.LANGFUSE_BASE_URL
delete process.env.LANGFUSE_USER_ID
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
mockForceFlush.mockClear()
mockShutdown.mockClear()
mockSetAttribute.mockClear()
})
// ── sanitize tests ──────────────────────────────────────────────────────────
describe('sanitizeToolInput', () => {
test('replaces home dir in file_path', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
const home = process.env.HOME ?? '/Users/testuser'
const result = sanitizeToolInput('FileReadTool', {
file_path: `${home}/project/file.ts`,
}) as Record<string, string>
expect(result.file_path).toBe('~/project/file.ts')
})
test('redacts sensitive keys', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
const result = sanitizeToolInput('MCPTool', {
api_key: 'secret123',
token: 'abc',
}) as Record<string, string>
expect(result.api_key).toBe('[REDACTED]')
expect(result.token).toBe('[REDACTED]')
})
test('returns non-object input unchanged', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
expect(sanitizeToolInput('BashTool', 'raw string')).toBe('raw string')
expect(sanitizeToolInput('BashTool', null)).toBe(null)
})
})
describe('sanitizeToolOutput', () => {
test('redacts FileReadTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('FileReadTool', 'file content here')
expect(result).toBe('[file content redacted, 17 chars]')
})
test('redacts FileWriteTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('FileWriteTool', 'written content')
expect(result).toBe('[file content redacted, 15 chars]')
})
test('truncates BashTool output over 500 chars', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const longOutput = 'x'.repeat(600)
const result = sanitizeToolOutput('BashTool', longOutput)
expect(result).toContain('[truncated]')
expect(result.length).toBeLessThan(600)
})
test('does not truncate BashTool output under 500 chars', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const shortOutput = 'hello world'
expect(sanitizeToolOutput('BashTool', shortOutput)).toBe('hello world')
})
test('redacts ConfigTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('ConfigTool', 'config data')
expect(result).toBe('[ConfigTool output redacted, 11 chars]')
})
test('redacts MCPTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('MCPTool', 'mcp data')
expect(result).toBe('[MCPTool output redacted, 8 chars]')
})
})
describe('sanitizeGlobal', () => {
test('replaces home dir in strings', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
const home = process.env.HOME ?? '/Users/testuser'
expect(sanitizeGlobal(`path: ${home}/file`)).toBe('path: ~/file')
})
test('recursively sanitizes nested objects', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
const result = sanitizeGlobal({
nested: { api_key: 'secret', name: 'test' },
}) as Record<string, Record<string, string>>
expect(result.nested.api_key).toBe('[REDACTED]')
expect(result.nested.name).toBe('test')
})
test('returns non-string/object values unchanged', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
expect(sanitizeGlobal(42)).toBe(42)
expect(sanitizeGlobal(true)).toBe(true)
})
})
// ── client tests ────────────────────────────────────────────────────────────
describe('isLangfuseEnabled', () => {
test('returns false when keys not configured', async () => {
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(false)
})
test('returns true when both keys are set', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(true)
})
})
describe('initLangfuse', () => {
test('returns false when keys not configured', async () => {
const { initLangfuse } = await import('../client.js')
expect(initLangfuse()).toBe(false)
})
test('returns true when keys are configured', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(true)
})
test('is idempotent — multiple calls do not re-initialize', async () => {
const { initLangfuse } = await import('../client.js')
expect(() => {
initLangfuse()
initLangfuse()
}).not.toThrow()
})
})
describe('shutdownLangfuse', () => {
test('calls forceFlush and shutdown on processor', async () => {
const { shutdownLangfuse } = await import('../client.js')
await expect(shutdownLangfuse()).resolves.toBeUndefined()
})
})
// ── tracing tests ───────────────────────────────────────────────────────────
describe('createTrace', () => {
test('returns null when langfuse not enabled', async () => {
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
test('creates root span when enabled', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
input: [],
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith(
'agent-run',
expect.objectContaining({
metadata: expect.objectContaining({
provider: 'firstParty',
model: 'claude-3',
agentType: 'main',
}),
}),
{ asType: 'agent' },
)
// Should set session.id attribute
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
})
})
describe('recordLLMObservation', () => {
test('no-ops when rootSpan is null', async () => {
const { recordLLMObservation } = await import('../tracing.js')
recordLLMObservation(null, {
model: 'm',
provider: 'firstParty',
input: [],
output: [],
usage: { input_tokens: 10, output_tokens: 5 },
})
expect(mockStartObservation).toHaveBeenCalledTimes(0)
})
test('records generation child observation via global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
recordLLMObservation(span, {
model: 'claude-3',
provider: 'firstParty',
input: [{ role: 'user', content: 'hello' }],
output: [{ role: 'assistant', content: 'hi' }],
usage: { input_tokens: 10, output_tokens: 5 },
})
// Should call the global startObservation with asType: 'generation' and parentSpanContext
expect(mockStartObservation).toHaveBeenCalledWith(
'ChatAnthropic',
expect.objectContaining({
model: 'claude-3',
}),
expect.objectContaining({
asType: 'generation',
parentSpanContext: mockSpanContext,
}),
)
expect(mockRootUpdate).toHaveBeenCalledWith(
expect.objectContaining({
usageDetails: { input: 10, output: 5 },
}),
)
expect(mockRootEnd).toHaveBeenCalled()
})
})
describe('recordToolObservation', () => {
test('no-ops when rootSpan is null', async () => {
const { recordToolObservation } = await import('../tracing.js')
recordToolObservation(null, {
toolName: 'BashTool',
toolUseId: 'id1',
input: {},
output: 'out',
})
})
test('records tool child observation via global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-1',
input: { command: 'ls' },
output: 'file.ts',
})
// Should call the global startObservation with asType: 'tool' and parentSpanContext
expect(mockStartObservation).toHaveBeenCalledWith(
'BashTool',
expect.objectContaining({
input: expect.any(Object),
}),
expect.objectContaining({
asType: 'tool',
parentSpanContext: mockSpanContext,
}),
)
expect(mockRootUpdate).toHaveBeenCalled()
expect(mockRootEnd).toHaveBeenCalled()
})
test('passes startTime to global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockStartObservation.mockClear()
const startTime = new Date('2026-01-01T00:00:00Z')
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-2',
input: {},
output: 'out',
startTime,
})
expect(mockStartObservation).toHaveBeenCalledWith(
'BashTool',
expect.any(Object),
expect.objectContaining({
startTime,
parentSpanContext: mockSpanContext,
}),
)
})
test('sanitizes FileReadTool output', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockRootUpdate.mockClear()
recordToolObservation(span, {
toolName: 'FileReadTool',
toolUseId: 'tu-2',
input: { file_path: '/tmp/file.ts' },
output: 'file content here',
})
expect(mockRootUpdate).toHaveBeenCalledWith(
expect.objectContaining({
output: '[file content redacted, 17 chars]',
}),
)
})
test('sets ERROR level for error observations', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockRootUpdate.mockClear()
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-3',
input: {},
output: 'error occurred',
isError: true,
})
expect(mockRootUpdate).toHaveBeenCalledWith(
expect.objectContaining({ level: 'ERROR' }),
)
})
})
describe('endTrace', () => {
test('no-ops when rootSpan is null', async () => {
const { endTrace } = await import('../tracing.js')
endTrace(null)
expect(mockRootEnd).not.toHaveBeenCalled()
})
test('calls span.end()', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, endTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockRootEnd.mockClear()
endTrace(span)
expect(mockRootEnd).toHaveBeenCalled()
})
test('calls span.update() with output when provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, endTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
endTrace(span, 'final output')
expect(mockRootUpdate).toHaveBeenCalledWith(
expect.objectContaining({ output: 'final output' }),
)
expect(mockRootEnd).toHaveBeenCalled()
})
})
describe('createSubagentTrace', () => {
test('returns null when langfuse not enabled', async () => {
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
test('creates trace with agentType and agentId metadata', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
input: [{ role: 'user', content: 'search for X' }],
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith(
'agent:Explore',
expect.objectContaining({
metadata: expect.objectContaining({
agentType: 'Explore',
agentId: 'agent-1',
provider: 'firstParty',
model: 'claude-3',
}),
}),
{ asType: 'agent' },
)
// Verify session.id attribute is set
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
})
test('returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => {
throw new Error('SDK error')
})
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Plan',
agentId: 'agent-2',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
})
describe('createTrace with querySource', () => {
test('includes querySource in metadata', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
querySource: 'user',
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith(
'agent-run:user',
expect.objectContaining({
metadata: expect.objectContaining({
agentType: 'main',
querySource: 'user',
}),
}),
{ asType: 'agent' },
)
})
test('omits querySource when not provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockClear()
const { createTrace } = await import('../tracing.js')
createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
const calls = mockStartObservation.mock.calls as unknown[][]
const secondArg = calls[0]?.[1] as Record<string, unknown> | undefined
const metadata = (secondArg?.metadata ?? {}) as Record<string, unknown>
expect(metadata).not.toHaveProperty('querySource')
})
})
describe('nested agent scenario', () => {
test('sub-agent trace shares sessionId with parent', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, createSubagentTrace } = await import('../tracing.js')
mockSetAttribute.mockClear()
// Create parent trace
const parentSpan = createTrace({
sessionId: 'shared-session',
model: 'claude-3',
provider: 'firstParty',
})
// Create sub-agent trace with same sessionId
const subSpan = createSubagentTrace({
sessionId: 'shared-session',
agentType: 'Explore',
agentId: 'agent-explore-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(parentSpan).not.toBeNull()
expect(subSpan).not.toBeNull()
// Both should have set session.id attribute
const sessionAttributeCalls = mockSetAttribute.mock.calls.filter(
(call: unknown[]) =>
Array.isArray(call) &&
call[0] === 'session.id' &&
call[1] === 'shared-session',
)
expect(sessionAttributeCalls.length).toBeGreaterThanOrEqual(2)
})
test('query reuses passed langfuseTrace instead of creating new one', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createSubagentTrace } = await import('../tracing.js')
const subTrace = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(subTrace).not.toBeNull()
// Simulate query.ts logic: if langfuseTrace already set, don't create new one
const ownsTrace = false
const langfuseTrace = subTrace
expect(ownsTrace).toBe(false)
expect(langfuseTrace).toBe(subTrace)
})
})
describe('SDK exceptions do not affect main flow', () => {
test('createTrace returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => {
throw new Error('SDK error')
})
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
test('recordLLMObservation silently fails on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import(
'../tracing.js'
)
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
// The next call to startObservation (for the generation) will throw
mockStartObservation.mockImplementationOnce(() => {
throw new Error('SDK error')
})
expect(() =>
recordLLMObservation(span, {
model: 'm',
provider: 'firstParty',
input: [],
output: [],
usage: { input_tokens: 1, output_tokens: 1 },
}),
).not.toThrow()
})
})
})

View File

@@ -1444,6 +1444,7 @@ export const connectToServer = memoize(
} }
// Wait for graceful shutdown with rapid escalation (total 500ms to keep CLI responsive) // Wait for graceful shutdown with rapid escalation (total 500ms to keep CLI responsive)
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: async needed for sequential await inside executor
await new Promise<void>(async resolve => { await new Promise<void>(async resolve => {
let resolved = false let resolved = false

View File

@@ -61,7 +61,6 @@ function handlePluginCommandError(
: command === 'disable-all' : command === 'disable-all'
? 'disable all plugins' ? 'disable all plugins'
: `${command} plugins` : `${command} plugins`
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
`${figures.cross} Failed to ${operation}: ${errorMessage(error)}`, `${figures.cross} Failed to ${operation}: ${errorMessage(error)}`,
) )
@@ -105,7 +104,6 @@ export async function installPlugin(
scope: InstallableScope = 'user', scope: InstallableScope = 'user',
): Promise<void> { ): Promise<void> {
try { try {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Installing plugin "${plugin}"...`) console.log(`Installing plugin "${plugin}"...`)
const result = await installPluginOp(plugin, scope) const result = await installPluginOp(plugin, scope)
@@ -114,7 +112,6 @@ export async function installPlugin(
throw new Error(result.message) throw new Error(result.message)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${figures.tick} ${result.message}`) console.log(`${figures.tick} ${result.message}`)
// _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns. // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns.
@@ -162,7 +159,6 @@ export async function uninstallPlugin(
throw new Error(result.message) throw new Error(result.message)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${figures.tick} ${result.message}`) console.log(`${figures.tick} ${result.message}`)
const { name, marketplace } = parsePluginIdentifier( const { name, marketplace } = parsePluginIdentifier(
@@ -203,7 +199,6 @@ export async function enablePlugin(
throw new Error(result.message) throw new Error(result.message)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${figures.tick} ${result.message}`) console.log(`${figures.tick} ${result.message}`)
const { name, marketplace } = parsePluginIdentifier( const { name, marketplace } = parsePluginIdentifier(
@@ -244,7 +239,6 @@ export async function disablePlugin(
throw new Error(result.message) throw new Error(result.message)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${figures.tick} ${result.message}`) console.log(`${figures.tick} ${result.message}`)
const { name, marketplace } = parsePluginIdentifier( const { name, marketplace } = parsePluginIdentifier(
@@ -280,7 +274,6 @@ export async function disableAllPlugins(): Promise<void> {
throw new Error(result.message) throw new Error(result.message)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${figures.tick} ${result.message}`) console.log(`${figures.tick} ${result.message}`)
logEvent('tengu_plugin_disabled_all_cli', {}) logEvent('tengu_plugin_disabled_all_cli', {})

View File

@@ -20,6 +20,7 @@ import {
} from './bootstrap/state.js' } from './bootstrap/state.js'
import { getCommands } from './commands.js' import { getCommands } from './commands.js'
import { initSessionMemory } from './services/SessionMemory/sessionMemory.js' import { initSessionMemory } from './services/SessionMemory/sessionMemory.js'
import { initSkillLearning } from './services/skillLearning/runtimeObserver.js'
import { asSessionId } from './types/ids.js' import { asSessionId } from './types/ids.js'
import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js' import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'
import { checkAndRestoreTerminalBackup } from './utils/appleTerminalBackup.js' import { checkAndRestoreTerminalBackup } from './utils/appleTerminalBackup.js'
@@ -68,8 +69,7 @@ export async function setup(
// Check for Node.js version < 18 // Check for Node.js version < 18
const nodeVersion = process.version.match(/^v(\d+)\./)?.[1] const nodeVersion = process.version.match(/^v(\d+)\./)?.[1]
if (!nodeVersion || parseInt(nodeVersion) < 18) { if (!nodeVersion || parseInt(nodeVersion, 10) < 18) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
chalk.bold.red( chalk.bold.red(
'Error: Claude Code requires Node.js version 18 or higher.', 'Error: Claude Code requires Node.js version 18 or higher.',
@@ -117,14 +117,12 @@ export async function setup(
if (isAgentSwarmsEnabled()) { if (isAgentSwarmsEnabled()) {
const restoredIterm2Backup = await checkAndRestoreITerm2Backup() const restoredIterm2Backup = await checkAndRestoreITerm2Backup()
if (restoredIterm2Backup.status === 'restored') { if (restoredIterm2Backup.status === 'restored') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
chalk.yellow( chalk.yellow(
'Detected an interrupted iTerm2 setup. Your original settings have been restored. You may need to restart iTerm2 for the changes to take effect.', 'Detected an interrupted iTerm2 setup. Your original settings have been restored. You may need to restart iTerm2 for the changes to take effect.',
), ),
) )
} else if (restoredIterm2Backup.status === 'failed') { } else if (restoredIterm2Backup.status === 'failed') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
chalk.red( chalk.red(
`Failed to restore iTerm2 settings. Please manually restore your original settings with: defaults import com.googlecode.iterm2 ${restoredIterm2Backup.backupPath}.`, `Failed to restore iTerm2 settings. Please manually restore your original settings with: defaults import com.googlecode.iterm2 ${restoredIterm2Backup.backupPath}.`,
@@ -137,14 +135,12 @@ export async function setup(
try { try {
const restoredTerminalBackup = await checkAndRestoreTerminalBackup() const restoredTerminalBackup = await checkAndRestoreTerminalBackup()
if (restoredTerminalBackup.status === 'restored') { if (restoredTerminalBackup.status === 'restored') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
chalk.yellow( chalk.yellow(
'Detected an interrupted Terminal.app setup. Your original settings have been restored. You may need to restart Terminal.app for the changes to take effect.', 'Detected an interrupted Terminal.app setup. Your original settings have been restored. You may need to restart Terminal.app for the changes to take effect.',
), ),
) )
} else if (restoredTerminalBackup.status === 'failed') { } else if (restoredTerminalBackup.status === 'failed') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
chalk.red( chalk.red(
`Failed to restore Terminal.app settings. Please manually restore your original settings with: defaults import com.apple.Terminal ${restoredTerminalBackup.backupPath}.`, `Failed to restore Terminal.app settings. Please manually restore your original settings with: defaults import com.apple.Terminal ${restoredTerminalBackup.backupPath}.`,
@@ -252,14 +248,12 @@ export async function setup(
worktreeSession.worktreePath, worktreeSession.worktreePath,
) )
if (tmuxResult.created) { if (tmuxResult.created) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
chalk.green( chalk.green(
`Created tmux session: ${chalk.bold(tmuxSessionName)}\nTo attach: ${chalk.bold(`tmux attach -t ${tmuxSessionName}`)}`, `Created tmux session: ${chalk.bold(tmuxSessionName)}\nTo attach: ${chalk.bold(`tmux attach -t ${tmuxSessionName}`)}`,
), ),
) )
} else { } else {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
chalk.yellow( chalk.yellow(
`Warning: Failed to create tmux session: ${tmuxResult.error}`, `Warning: Failed to create tmux session: ${tmuxResult.error}`,
@@ -292,6 +286,7 @@ export async function setup(
// raced ahead and memoized an empty bundledSkills list. // raced ahead and memoized an empty bundledSkills list.
if (!isBareMode()) { if (!isBareMode()) {
initSessionMemory() // Synchronous - registers hook, gate check happens lazily initSessionMemory() // Synchronous - registers hook, gate check happens lazily
initSkillLearning() // Synchronous - registers hook, gate check happens lazily
if (feature('CONTEXT_COLLAPSE')) { if (feature('CONTEXT_COLLAPSE')) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
;( ;(
@@ -406,7 +401,6 @@ export async function setup(
process.env.IS_SANDBOX !== '1' && process.env.IS_SANDBOX !== '1' &&
!isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP) !isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP)
) { ) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
`--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`, `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`,
) )
@@ -432,7 +426,6 @@ export async function setup(
const isSandbox = process.env.IS_SANDBOX === '1' const isSandbox = process.env.IS_SANDBOX === '1'
const isSandboxed = isDocker || isBubblewrap || isSandbox const isSandboxed = isDocker || isBubblewrap || isSandbox
if (!isSandboxed || hasInternet) { if (!isSandboxed || hasInternet) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
`--dangerously-skip-permissions can only be used in Docker/sandbox containers with no internet access but got Docker: ${isDocker}, Bubblewrap: ${isBubblewrap}, IS_SANDBOX: ${isSandbox}, hasInternet: ${hasInternet}`, `--dangerously-skip-permissions can only be used in Docker/sandbox containers with no internet access but got Docker: ${isDocker}, Bubblewrap: ${isBubblewrap}, IS_SANDBOX: ${isSandbox}, hasInternet: ${hasInternet}`,
) )

View File

@@ -34,8 +34,8 @@ import typescriptClaudeApiToolUse from './claude-api/typescript/claude-api/tool-
// - claude-api/SKILL.md (Current Models pricing table) // - claude-api/SKILL.md (Current Models pricing table)
// - claude-api/shared/models.md (full model catalog with legacy versions and alias mappings) // - claude-api/shared/models.md (full model catalog with legacy versions and alias mappings)
export const SKILL_MODEL_VARS = { export const SKILL_MODEL_VARS = {
OPUS_ID: 'claude-opus-4-6', OPUS_ID: 'claude-opus-4-7',
OPUS_NAME: 'Claude Opus 4.6', OPUS_NAME: 'Claude Opus 4.7',
SONNET_ID: 'claude-sonnet-4-6', SONNET_ID: 'claude-sonnet-4-6',
SONNET_NAME: 'Claude Sonnet 4.6', SONNET_NAME: 'Claude Sonnet 4.6',
HAIKU_ID: 'claude-haiku-4-5', HAIKU_ID: 'claude-haiku-4-5',

View File

@@ -243,7 +243,7 @@ export function registerLoremIpsumSkill(): void {
argumentHint: '[token_count]', argumentHint: '[token_count]',
userInvocable: true, userInvocable: true,
async getPromptForCommand(args) { async getPromptForCommand(args) {
const parsed = parseInt(args) const parsed = parseInt(args, 10)
if (args && (isNaN(parsed) || parsed <= 0)) { if (args && (isNaN(parsed) || parsed <= 0)) {
return [ return [