diff --git a/src/assistant/__tests__/index.test.ts b/src/assistant/__tests__/index.test.ts new file mode 100644 index 000000000..48201c33a --- /dev/null +++ b/src/assistant/__tests__/index.test.ts @@ -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') + }) +}) diff --git a/src/assistant/index.ts b/src/assistant/index.ts index c13d91b11..3b5e4538c 100644 --- a/src/assistant/index.ts +++ b/src/assistant/index.ts @@ -1,7 +1,24 @@ import { readFileSync } from 'fs' 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 { 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 @@ -29,13 +46,67 @@ export function isAssistantForced(): boolean { * Pre-create an in-process team so Agent(name) can spawn teammates * without TeamCreate. * - * Phase 1: returns undefined so main.tsx's `assistantTeamContext ?? computeInitialTeamContext()` - * correctly falls back. Returning {} would bypass the ?? operator since {} is truthy. - * - * Phase 2: should return a full team context object matching AppState.teamContext shape. + * Creates a session-scoped assistant team file and returns a full team + * context object matching AppState.teamContext. */ -export async function initializeAssistantTeam(): Promise { - return undefined +export async function initializeAssistantTeam(): Promise< + 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, + }, + }, + } } /** diff --git a/src/bridge/bridgeMain.ts b/src/bridge/bridgeMain.ts index b1fa496ed..0819eab6e 100644 --- a/src/bridge/bridgeMain.ts +++ b/src/bridge/bridgeMain.ts @@ -1963,7 +1963,6 @@ NOTES - 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 ${serverNote}` - // biome-ignore lint/suspicious/noConsole: intentional help output console.log(help) } @@ -2002,7 +2001,6 @@ export async function bridgeMain(args: string[]): Promise { return } if (parsed.error) { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error(`Error: ${parsed.error}`) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) @@ -2041,7 +2039,6 @@ export async function bridgeMain(args: string[]): Promise { const { PERMISSION_MODES } = await import('../types/permissions.js') const valid: readonly string[] = PERMISSION_MODES if (!valid.includes(permissionMode)) { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`, ) @@ -2084,7 +2081,6 @@ export async function bridgeMain(args: string[]): Promise { Promise.all([shutdown1PEventLogging(), shutdownDatadog()]), sleep(500, undefined, { unref: true }), ]).catch(() => {}) - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( 'Error: Multi-session Remote Control is not enabled for your account yet.', ) @@ -2101,7 +2097,6 @@ export async function bridgeMain(args: string[]): Promise { // 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. if (!checkHasTrustDialogAccepted()) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `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 { const bridgeToken = getBridgeAccessToken() if (!bridgeToken) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(BRIDGE_LOGIN_ERROR) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) @@ -2137,7 +2131,6 @@ export async function bridgeMain(args: string[]): Promise { input: process.stdin, output: process.stdout, }) - // biome-ignore lint/suspicious/noConsole:: intentional console output 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', ) @@ -2169,7 +2162,6 @@ export async function bridgeMain(args: string[]): Promise { ) const found = await readBridgePointerAcrossWorktrees(dir) if (!found) { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `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 { const ageMin = Math.round(pointer.ageMs / 60_000) const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h` const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : '' - // biome-ignore lint/suspicious/noConsole: intentional info output console.error( `Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`, ) @@ -2201,7 +2192,6 @@ export async function bridgeMain(args: string[]): Promise { !baseUrl.includes('localhost') && !baseUrl.includes('127.0.0.1') ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( '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 { ? getCurrentProjectConfig().remoteControlSpawnMode : undefined if (savedSpawnMode === 'worktree' && !worktreeAvailable) { - // biome-ignore lint/suspicious/noConsole: intentional warning output console.error( '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 { input: process.stdin, output: process.stdout, }) - // biome-ignore lint/suspicious/noConsole: intentional dialog output 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` + `Spawn mode for this project:\n` + @@ -2343,7 +2331,6 @@ export async function bridgeMain(args: string[]): Promise { // Only reachable via explicit --spawn=worktree (default is same-dir); // saved worktree pref was already guarded above. if (spawnMode === 'worktree' && !worktreeAvailable) { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `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 { try { validateBridgeId(resumeSessionId, 'sessionId') } catch { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`, ) @@ -2404,7 +2390,6 @@ export async function bridgeMain(args: string[]): Promise { const { clearBridgePointer } = await import('./bridgePointer.js') await clearBridgePointer(resumePointerDir) } - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `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 { const { clearBridgePointer } = await import('./bridgePointer.js') await clearBridgePointer(resumePointerDir) } - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `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 { status: err instanceof BridgeFatalError ? err.status : undefined, }) // Registration failures are fatal — print a clean message instead of a stack trace. - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( err instanceof BridgeFatalError && err.status === 404 ? 'Remote Control environments are not available for your account.' @@ -2495,7 +2478,6 @@ export async function bridgeMain(args: string[]): Promise { `Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`, ), ) - // biome-ignore lint/suspicious/noConsole: intentional warning output console.warn( `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 { const { clearBridgePointer } = await import('./bridgePointer.js') await clearBridgePointer(resumePointerDir) } - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( isFatal ? `Error: ${errorMessage(err)}` diff --git a/src/cli/exit.ts b/src/cli/exit.ts index 99e56f97b..b31fcf904 100644 --- a/src/cli/exit.ts +++ b/src/cli/exit.ts @@ -17,7 +17,6 @@ /** Write an error message to stderr (if given) and exit with code 1. */ export function cliError(msg?: string): never { - // biome-ignore lint/suspicious/noConsole: centralized CLI error output if (msg) console.error(msg) process.exit(1) return undefined as never diff --git a/src/cli/handlers/agents.ts b/src/cli/handlers/agents.ts index f02ce8e1d..5f2a40493 100644 --- a/src/cli/handlers/agents.ts +++ b/src/cli/handlers/agents.ts @@ -59,12 +59,9 @@ export async function agentsHandler(): Promise { } if (lines.length === 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('No agents found.') } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${totalActive} active agents\n`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(lines.join('\n').trimEnd()) } } diff --git a/src/cli/handlers/plugins.ts b/src/cli/handlers/plugins.ts index 9236abe0a..8a3c48a30 100644 --- a/src/cli/handlers/plugins.ts +++ b/src/cli/handlers/plugins.ts @@ -72,27 +72,21 @@ export function handleMarketplaceError(error: unknown, action: string): never { function printValidationResult(result: ValidationResult): void { if (result.errors.length > 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`, ) result.errors.forEach(error => { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${error.path}: ${error.message}`) }) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') } if (result.warnings.length > 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`, ) result.warnings.forEach(warning => { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`) }) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') } } @@ -106,7 +100,6 @@ export async function pluginValidateHandler( try { const result = await validateManifest(manifestPath) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`) printValidationResult(result) @@ -120,7 +113,6 @@ export async function pluginValidateHandler( if (basename(manifestDir) === '.claude-plugin') { contentResults = await validatePluginContents(dirname(manifestDir)) for (const r of contentResults) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Validating ${r.fileType}: ${r.filePath}\n`) printValidationResult(r) } @@ -139,13 +131,11 @@ export async function pluginValidateHandler( : `${figures.tick} Validation passed`, ) } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.cross} Validation failed`) process.exit(1) } } catch (error) { logError(error) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `${figures.cross} Unexpected error during validation: ${errorMessage(error)}`, ) @@ -358,7 +348,6 @@ export async function pluginListHandler(options: { } if (pluginIds.length > 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('Installed plugins:\n') } @@ -383,25 +372,18 @@ export async function pluginListHandler(options: { const version = installation.version || 'unknown' const scope = installation.scope - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${pluginId}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Version: ${version}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Scope: ${scope}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Status: ${status}`) for (const error of pluginErrors) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Error: ${getPluginErrorMessage(error)}`) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') } } if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('Session-only plugins (--plugin-dir):\n') for (const p of inlinePlugins) { // Same dirName≠manifestName fallback as the JSON path above — error @@ -413,19 +395,13 @@ export async function pluginListHandler(options: { pErrors.length > 0 ? `${figures.cross} loaded with errors` : `${figures.tick} loaded` - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${p.source}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Version: ${p.manifest.version ?? 'unknown'}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Path: ${p.path}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Status: ${status}`) for (const e of pErrors) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Error: ${getPluginErrorMessage(e)}`) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') } // 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 => e.source.startsWith('inline['), )) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( ` ${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...') const { name, alreadyMaterialized, resolvedSource } = await addMarketplaceSource(marketplaceSource, message => { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(message) }) @@ -555,33 +528,25 @@ export async function marketplaceListHandler(options: { cliOk('No marketplaces configured') } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('Configured marketplaces:\n') names.forEach(name => { const marketplace = config[name] - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${name}`) if (marketplace?.source) { const src = marketplace.source if (src.source === 'github') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: GitHub (${src.repo})`) } else if (src.source === 'git') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: Git (${src.url})`) } else if (src.source === 'url') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: URL (${src.url})`) } else if (src.source === 'directory') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: Directory (${src.path})`) } else if (src.source === 'file') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: File (${src.path})`) } } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') }) @@ -620,11 +585,9 @@ export async function marketplaceUpdateHandler( if (options.cowork) setUseCoworkPlugins(true) try { if (name) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Updating marketplace: ${name}...`) await refreshMarketplace(name, message => { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(message) }) @@ -644,7 +607,6 @@ export async function marketplaceUpdateHandler( cliOk('No marketplaces configured') } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Updating ${marketplaceNames.length} marketplace(s)...`) await refreshAllMarketplaces() diff --git a/src/cli/structuredIO.ts b/src/cli/structuredIO.ts index fba44e61b..1566a5458 100644 --- a/src/cli/structuredIO.ts +++ b/src/cli/structuredIO.ts @@ -462,7 +462,6 @@ export class StructuredIO { } return message } catch (error) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(`Error parsing streaming input line: ${line}: ${error}`) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) @@ -687,7 +686,6 @@ export class StructuredIO { ) return result } catch (error) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(`Error in hook callback ${callbackId}:`, error) return {} } @@ -781,7 +779,6 @@ export class StructuredIO { } function exitWithMessage(message: string): never { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(message) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) diff --git a/src/history.ts b/src/history.ts index 3f6234321..a51970368 100644 --- a/src/history.ts +++ b/src/history.ts @@ -67,7 +67,7 @@ export function parseReferences( const matches = [...input.matchAll(referencePattern)] return matches .map(match => ({ - id: parseInt(match[2] || '0'), + id: parseInt(match[2] || '0', 10), match: match[0], index: match.index, })) diff --git a/src/hooks/notifs/useModelMigrationNotifications.tsx b/src/hooks/notifs/useModelMigrationNotifications.tsx index b2bdc52fb..c1ed7cdf3 100644 --- a/src/hooks/notifs/useModelMigrationNotifications.tsx +++ b/src/hooks/notifs/useModelMigrationNotifications.tsx @@ -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 - // current Opus default (4.6 for 1P). + // current Opus default (4.7 for 1P). c => { const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp) const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp @@ -27,8 +27,8 @@ const MIGRATIONS: ((c: GlobalConfig) => Notification | undefined)[] = [ return { key: 'opus-pro-update', text: isLegacyRemap - ? 'Model updated to Opus 4.6 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' - : 'Model updated to Opus 4.6', + ? 'Model updated to Opus 4.7 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' + : 'Model updated to Opus 4.7', color: 'suggestion', priority: 'high', timeoutMs: isLegacyRemap ? 8000 : 3000, diff --git a/src/hooks/useIssueFlagBanner.ts b/src/hooks/useIssueFlagBanner.ts index c21789cec..49161fe95 100644 --- a/src/hooks/useIssueFlagBanner.ts +++ b/src/hooks/useIssueFlagBanner.ts @@ -97,16 +97,13 @@ export function useIssueFlagBanner( return false } - // biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant const lastTriggeredAtRef = useRef(0) - // biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant const activeForSubmitRef = useRef(-1) // Memoize the O(messages) scans. This hook runs on every REPL render // (including every keystroke), but messages is stable during typing. // isSessionContainerCompatible walks all messages + regex-tests each // 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( () => isSessionContainerCompatible(messages) && hasFrictionSignal(messages), [messages], diff --git a/src/hooks/useTextInput.ts b/src/hooks/useTextInput.ts index 21e0dbf19..5cf16e261 100644 --- a/src/hooks/useTextInput.ts +++ b/src/hooks/useTextInput.ts @@ -24,6 +24,7 @@ import type { ImageDimensions } from '../utils/imageResizer.js' import { isModifierPressed, prewarmModifiers } from '../utils/modifiers.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 InputHandler = (input: string) => MaybeCursor type InputMapper = (input: string) => MaybeCursor diff --git a/src/hooks/useTypeahead.tsx b/src/hooks/useTypeahead.tsx index 6625586d3..1eece26e3 100644 --- a/src/hooks/useTypeahead.tsx +++ b/src/hooks/useTypeahead.tsx @@ -584,7 +584,6 @@ export function useTypeahead({ const debouncedFetchSlackChannels = useDebounceCallback(fetchSlackChannels, 150); // 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( async (value: string, inputCursorOffset?: number): Promise => { // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) diff --git a/src/migrations/migrateLegacyOpusToCurrent.ts b/src/migrations/migrateLegacyOpusToCurrent.ts index bdca4aada..075861f48 100644 --- a/src/migrations/migrateLegacyOpusToCurrent.ts +++ b/src/migrations/migrateLegacyOpusToCurrent.ts @@ -13,7 +13,7 @@ import { /** * 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. * parseUserSpecifiedModel now silently remaps these at runtime anyway — * this migration cleans up the settings file so /model shows the right diff --git a/src/native-ts/file-index/index.ts b/src/native-ts/file-index/index.ts index 7eb9f4fa1..b0cec42b1 100644 --- a/src/native-ts/file-index/index.ts +++ b/src/native-ts/file-index/index.ts @@ -48,6 +48,7 @@ export class FileIndex { private topLevelCache: SearchResult[] | null = null // During async build, tracks how many paths have bitmap/lowerPath filled. // search() uses this to search the ready prefix while build continues. + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: used via destructuring in search() private readyCount = 0 /** diff --git a/src/native-ts/yoga-layout/index.ts b/src/native-ts/yoga-layout/index.ts index 49b9602be..35e57cf8c 100644 --- a/src/native-ts/yoga-layout/index.ts +++ b/src/native-ts/yoga-layout/index.ts @@ -111,6 +111,7 @@ function isDefined(n: number): boolean { // NaN-safe equality for layout-cache input comparison 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) } @@ -2372,12 +2373,14 @@ function boundAxis( if (v > maxV.value) v = maxV.value } else if (maxU === 2) { 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 (minU === 1) { if (v < minV.value) v = minV.value } else if (minU === 2) { 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 } return v diff --git a/src/services/analytics/firstPartyEventLogger.ts b/src/services/analytics/firstPartyEventLogger.ts index e3a501d74..b54c43e12 100644 --- a/src/services/analytics/firstPartyEventLogger.ts +++ b/src/services/analytics/firstPartyEventLogger.ts @@ -331,6 +331,7 @@ export function initialize1PEventLogging(): void { parseInt( process.env.OTEL_LOGS_EXPORT_INTERVAL || DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(), + 10, ) const maxExportBatchSize = diff --git a/src/services/analytics/growthbook.ts b/src/services/analytics/growthbook.ts index 5e35514ea..4091f40fb 100644 --- a/src/services/analytics/growthbook.ts +++ b/src/services/analytics/growthbook.ts @@ -470,6 +470,10 @@ const LOCAL_GATE_DEFAULTS: Record = { tengu_kairos_cron_durable: true, // Persistent cron tasks tengu_attribution_header: true, // API request attribution header 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 } /** diff --git a/src/services/langfuse/__tests__/langfuse.isolated.ts b/src/services/langfuse/__tests__/langfuse.isolated.ts new file mode 100644 index 000000000..815aaf9e4 --- /dev/null +++ b/src/services/langfuse/__tests__/langfuse.isolated.ts @@ -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 = { + 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 + 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 + 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> + 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 | undefined + const metadata = (secondArg?.metadata ?? {}) as Record + 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() + }) + }) +}) diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index 00576954d..ac9302a18 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -1444,6 +1444,7 @@ export const connectToServer = memoize( } // 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(async resolve => { let resolved = false diff --git a/src/services/plugins/pluginCliCommands.ts b/src/services/plugins/pluginCliCommands.ts index 514a4143e..917658768 100644 --- a/src/services/plugins/pluginCliCommands.ts +++ b/src/services/plugins/pluginCliCommands.ts @@ -61,7 +61,6 @@ function handlePluginCommandError( : command === 'disable-all' ? 'disable all plugins' : `${command} plugins` - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `${figures.cross} Failed to ${operation}: ${errorMessage(error)}`, ) @@ -105,7 +104,6 @@ export async function installPlugin( scope: InstallableScope = 'user', ): Promise { try { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Installing plugin "${plugin}"...`) const result = await installPluginOp(plugin, scope) @@ -114,7 +112,6 @@ export async function installPlugin( throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns. @@ -162,7 +159,6 @@ export async function uninstallPlugin( throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( @@ -203,7 +199,6 @@ export async function enablePlugin( throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( @@ -244,7 +239,6 @@ export async function disablePlugin( throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( @@ -280,7 +274,6 @@ export async function disableAllPlugins(): Promise { throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) logEvent('tengu_plugin_disabled_all_cli', {}) diff --git a/src/setup.ts b/src/setup.ts index 985e8577a..9b299bff0 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -20,6 +20,7 @@ import { } from './bootstrap/state.js' import { getCommands } from './commands.js' import { initSessionMemory } from './services/SessionMemory/sessionMemory.js' +import { initSkillLearning } from './services/skillLearning/runtimeObserver.js' import { asSessionId } from './types/ids.js' import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js' import { checkAndRestoreTerminalBackup } from './utils/appleTerminalBackup.js' @@ -68,8 +69,7 @@ export async function setup( // Check for Node.js version < 18 const nodeVersion = process.version.match(/^v(\d+)\./)?.[1] - if (!nodeVersion || parseInt(nodeVersion) < 18) { - // biome-ignore lint/suspicious/noConsole:: intentional console output + if (!nodeVersion || parseInt(nodeVersion, 10) < 18) { console.error( chalk.bold.red( 'Error: Claude Code requires Node.js version 18 or higher.', @@ -117,14 +117,12 @@ export async function setup( if (isAgentSwarmsEnabled()) { const restoredIterm2Backup = await checkAndRestoreITerm2Backup() if (restoredIterm2Backup.status === 'restored') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( 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.', ), ) } else if (restoredIterm2Backup.status === 'failed') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.red( `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 { const restoredTerminalBackup = await checkAndRestoreTerminalBackup() if (restoredTerminalBackup.status === 'restored') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( 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.', ), ) } else if (restoredTerminalBackup.status === 'failed') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.red( `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, ) if (tmuxResult.created) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( chalk.green( `Created tmux session: ${chalk.bold(tmuxSessionName)}\nTo attach: ${chalk.bold(`tmux attach -t ${tmuxSessionName}`)}`, ), ) } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.yellow( `Warning: Failed to create tmux session: ${tmuxResult.error}`, @@ -292,6 +286,7 @@ export async function setup( // raced ahead and memoized an empty bundledSkills list. if (!isBareMode()) { initSessionMemory() // Synchronous - registers hook, gate check happens lazily + initSkillLearning() // Synchronous - registers hook, gate check happens lazily if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ ;( @@ -406,7 +401,6 @@ export async function setup( process.env.IS_SANDBOX !== '1' && !isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP) ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `--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 isSandboxed = isDocker || isBubblewrap || isSandbox if (!isSandboxed || hasInternet) { - // biome-ignore lint/suspicious/noConsole:: intentional console output 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}`, ) diff --git a/src/skills/bundled/claudeApiContent.ts b/src/skills/bundled/claudeApiContent.ts index cba76a3b3..820785002 100644 --- a/src/skills/bundled/claudeApiContent.ts +++ b/src/skills/bundled/claudeApiContent.ts @@ -34,8 +34,8 @@ import typescriptClaudeApiToolUse from './claude-api/typescript/claude-api/tool- // - claude-api/SKILL.md (Current Models pricing table) // - claude-api/shared/models.md (full model catalog with legacy versions and alias mappings) export const SKILL_MODEL_VARS = { - OPUS_ID: 'claude-opus-4-6', - OPUS_NAME: 'Claude Opus 4.6', + OPUS_ID: 'claude-opus-4-7', + OPUS_NAME: 'Claude Opus 4.7', SONNET_ID: 'claude-sonnet-4-6', SONNET_NAME: 'Claude Sonnet 4.6', HAIKU_ID: 'claude-haiku-4-5', diff --git a/src/skills/bundled/loremIpsum.ts b/src/skills/bundled/loremIpsum.ts index 053306c6f..de4c1a023 100644 --- a/src/skills/bundled/loremIpsum.ts +++ b/src/skills/bundled/loremIpsum.ts @@ -243,7 +243,7 @@ export function registerLoremIpsumSkill(): void { argumentHint: '[token_count]', userInvocable: true, async getPromptForCommand(args) { - const parsed = parseInt(args) + const parsed = parseInt(args, 10) if (args && (isNaN(parsed) || parsed <= 0)) { return [