diff --git a/src/components/CustomSelect/use-multi-select-state.ts b/src/components/CustomSelect/use-multi-select-state.ts index a089a20d4..66ca78d70 100644 --- a/src/components/CustomSelect/use-multi-select-state.ts +++ b/src/components/CustomSelect/use-multi-select-state.ts @@ -381,7 +381,7 @@ export function useMultiSelectState({ // Handle numeric keys (1-9) for direct selection if (!hideIndexes && /^[0-9]+$/.test(normalizedInput)) { - const index = parseInt(normalizedInput) - 1 + const index = parseInt(normalizedInput, 10) - 1 if (index >= 0 && index < options.length) { const value = options[index]!.value const newValues = selectedValues.includes(value) diff --git a/src/components/CustomSelect/use-select-input.ts b/src/components/CustomSelect/use-select-input.ts index b289056ee..0dcccc3c1 100644 --- a/src/components/CustomSelect/use-select-input.ts +++ b/src/components/CustomSelect/use-select-input.ts @@ -255,7 +255,7 @@ export const useSelectInput = ({ disableSelection !== 'numeric' && /^[0-9]+$/.test(normalizedInput) ) { - const index = parseInt(normalizedInput) - 1 + const index = parseInt(normalizedInput, 10) - 1 if (index >= 0 && index < state.options.length) { const selectedOption = state.options[index]! if (selectedOption.disabled === true) { diff --git a/src/components/messageActions.tsx b/src/components/messageActions.tsx index 098aff640..ceb6eb8c2 100644 --- a/src/components/messageActions.tsx +++ b/src/components/messageActions.tsx @@ -62,7 +62,6 @@ export function isNavigableMessage(msg: NavigableMessage): boolean { return !stripSystemReminders(b.text!).startsWith('<') } case 'system': - // biome-ignore lint/nursery/useExhaustiveSwitchCases: blocklist — fallthrough return-true is the design switch (msg.subtype) { case 'api_metrics': case 'stop_hook_summary': diff --git a/src/context/notifications.tsx b/src/context/notifications.tsx index a19d908f9..9ca11a6f2 100644 --- a/src/context/notifications.tsx +++ b/src/context/notifications.tsx @@ -288,7 +288,6 @@ export function useNotifications(): { // Imperative read (not useAppState) — a subscription in a mount-only // effect would be vestigial and make every caller re-render on queue changes. // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect, store is a stable context ref useEffect(() => { if (store.getState().notifications.queue.length > 0) { processQueue() diff --git a/src/utils/__tests__/modifiers.test.ts b/src/utils/__tests__/modifiers.test.ts new file mode 100644 index 000000000..e059b69e1 --- /dev/null +++ b/src/utils/__tests__/modifiers.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' + +let nativePrewarmCalls = 0 +let nativeReturnValue = false +let nativeShouldThrow = false + +const nativeIsModifierPressed = mock((modifier: string) => { + if (nativeShouldThrow) { + throw new Error('native modifier failure') + } + return nativeReturnValue +}) + +mock.module('modifiers-napi', () => ({ + prewarm: async () => { + nativePrewarmCalls++ + }, + isModifierPressed: nativeIsModifierPressed, +})) + +const originalPlatform = process.platform + +async function loadModule() { + return import(`../modifiers.ts?case=${Math.random()}`) +} + +beforeEach(() => { + nativePrewarmCalls = 0 + nativeReturnValue = false + nativeShouldThrow = false + nativeIsModifierPressed.mockClear() + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }) +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }) +}) + +describe('src/utils/modifiers', () => { + test('does not touch the native module on non-darwin', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }) + const mod = await loadModule() + + mod.prewarmModifiers() + expect(nativePrewarmCalls).toBe(0) + expect(mod.isModifierPressed('shift')).toBe(false) + expect(nativeIsModifierPressed).not.toHaveBeenCalled() + }) + + test('caches native prewarm after the first darwin call', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }) + const mod = await loadModule() + + mod.prewarmModifiers() + mod.prewarmModifiers() + + // prewarm is fire-and-forget async — flush microtasks + await new Promise(resolve => setTimeout(resolve, 0)) + expect(nativePrewarmCalls).toBe(1) + }) + + test('forwards modifier checks to the native module on darwin', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }) + nativeReturnValue = true + const mod = await loadModule() + + expect(mod.isModifierPressed('shift')).toBe(true) + expect(nativeIsModifierPressed).toHaveBeenCalledWith('shift') + }) + + test('returns false when native modifier checks throw on darwin', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }) + nativeShouldThrow = true + const mod = await loadModule() + + expect(mod.isModifierPressed('shift')).toBe(false) + }) +}) diff --git a/src/utils/__tests__/pipeStatus.test.ts b/src/utils/__tests__/pipeStatus.test.ts new file mode 100644 index 000000000..c5fb071fc --- /dev/null +++ b/src/utils/__tests__/pipeStatus.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { writeRegistry } from '../pipeRegistry' +import { formatPipeRegistryStatus } from '../pipeStatus' + +let tempDir: string +let previousConfigDir: string | undefined + +beforeEach(() => { + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + tempDir = join( + tmpdir(), + `pipe-status-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) + process.env.CLAUDE_CONFIG_DIR = tempDir +}) + +afterEach(async () => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + await rm(tempDir, { recursive: true, force: true }) +}) + +describe('pipe status', () => { + test('formats registry main and sub pipe communication state', async () => { + await writeRegistry({ + version: 1, + mainMachineId: 'machine-main-123456', + main: { + id: 'main-id', + pid: 123, + machineId: 'machine-main-123456', + startedAt: 1, + ip: '127.0.0.1', + mac: '00:11:22:33:44:55', + hostname: 'main-host', + pipeName: 'main-pipe', + tcpPort: 43123, + }, + subs: [ + { + id: 'sub-id', + pid: 456, + machineId: 'machine-sub-123456', + startedAt: 2, + ip: '127.0.0.2', + mac: '66:77:88:99:aa:bb', + hostname: 'sub-host', + pipeName: 'sub-pipe', + tcpPort: 43124, + subIndex: 1, + boundToMain: 'main-pipe', + }, + ], + }) + + const formatted = await formatPipeRegistryStatus() + + expect(formatted).toContain('Pipe registry: 1 main, 1 sub(s)') + expect(formatted).toContain('[main] main-pipe') + expect(formatted).toContain('[sub-1] sub-pipe') + expect(formatted).toContain('bound=main-pipe') + }) +}) diff --git a/src/utils/__tests__/remoteControlStatus.test.ts b/src/utils/__tests__/remoteControlStatus.test.ts new file mode 100644 index 000000000..16bf91f59 --- /dev/null +++ b/src/utils/__tests__/remoteControlStatus.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { formatRemoteControlLocalStatus } from '../remoteControlStatus' + +let previousBaseUrl: string | undefined +let previousToken: string | undefined + +beforeEach(() => { + previousBaseUrl = process.env.CLAUDE_BRIDGE_BASE_URL + previousToken = process.env.CLAUDE_BRIDGE_OAUTH_TOKEN +}) + +afterEach(() => { + if (previousBaseUrl === undefined) { + delete process.env.CLAUDE_BRIDGE_BASE_URL + } else { + process.env.CLAUDE_BRIDGE_BASE_URL = previousBaseUrl + } + if (previousToken === undefined) { + delete process.env.CLAUDE_BRIDGE_OAUTH_TOKEN + } else { + process.env.CLAUDE_BRIDGE_OAUTH_TOKEN = previousToken + } +}) + +describe('remote control status', () => { + test('formats self-hosted bridge local config without remote calls', () => { + process.env.CLAUDE_BRIDGE_BASE_URL = 'http://127.0.0.1:8787' + process.env.CLAUDE_BRIDGE_OAUTH_TOKEN = 'token' + + const status = formatRemoteControlLocalStatus() + + expect(status).toContain('Remote Control: self-hosted') + expect(status).toContain('base_url=http://127.0.0.1:8787') + expect(status).toContain('token=present') + expect(status).toContain('entitlement=checked at remote-control startup') + }) +}) diff --git a/src/utils/__tests__/remoteTriggerAudit.test.ts b/src/utils/__tests__/remoteTriggerAudit.test.ts new file mode 100644 index 000000000..d169449a8 --- /dev/null +++ b/src/utils/__tests__/remoteTriggerAudit.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { + appendRemoteTriggerAuditRecord, + formatRemoteTriggerAuditStatus, + listRemoteTriggerAuditRecords, +} from '../remoteTriggerAudit' + +let tempDir = '' + +beforeEach(() => { + tempDir = join( + tmpdir(), + `remote-trigger-audit-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) +}) + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) +}) + +describe('remote trigger audit', () => { + test('records and formats local remote trigger audit events', async () => { + await appendRemoteTriggerAuditRecord( + { action: 'run', triggerId: 'abc', ok: true, status: 200, createdAt: 1 }, + tempDir, + ) + await appendRemoteTriggerAuditRecord( + { action: 'create', ok: false, error: 'bad request', createdAt: 2 }, + tempDir, + ) + + const records = await listRemoteTriggerAuditRecords(tempDir) + expect(records).toHaveLength(2) + expect(records[0].action).toBe('create') + expect(formatRemoteTriggerAuditStatus(records)).toContain( + 'RemoteTrigger audit records: 2', + ) + expect(formatRemoteTriggerAuditStatus(records)).toContain('Failures: 1') + }) +}) diff --git a/src/utils/__tests__/teamDiscovery.test.ts b/src/utils/__tests__/teamDiscovery.test.ts new file mode 100644 index 000000000..4ec97295b --- /dev/null +++ b/src/utils/__tests__/teamDiscovery.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getTeammateStatuses } from '../teamDiscovery' + +let tempHome: string +let previousConfigDir: string | undefined + +beforeEach(() => { + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + tempHome = join( + tmpdir(), + `team-discovery-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) + process.env.CLAUDE_CONFIG_DIR = tempHome +}) + +afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + rmSync(tempHome, { recursive: true, force: true }) +}) + +function writeTeamConfig(teamName: string, config: unknown): void { + const teamDir = join(tempHome, 'teams', teamName) + mkdirSync(teamDir, { recursive: true }) + writeFileSync(join(teamDir, 'config.json'), JSON.stringify(config, null, 2)) +} + +describe('getTeammateStatuses', () => { + test('preserves in-process backend type for lifecycle actions', () => { + writeTeamConfig('alpha', { + name: 'alpha', + createdAt: Date.now(), + leadAgentId: 'team-lead@alpha', + members: [ + { + agentId: 'team-lead@alpha', + name: 'team-lead', + joinedAt: Date.now(), + tmuxPaneId: '', + cwd: tempHome, + subscriptions: [], + }, + { + agentId: 'worker@alpha', + name: 'worker', + joinedAt: Date.now(), + tmuxPaneId: 'in-process', + cwd: tempHome, + subscriptions: [], + backendType: 'in-process', + }, + ], + }) + + expect(getTeammateStatuses('alpha')).toEqual([ + expect.objectContaining({ + agentId: 'worker@alpha', + backendType: 'in-process', + }), + ]) + }) +}) diff --git a/src/utils/__tests__/tokens.test.ts b/src/utils/__tests__/tokens.test.ts index 5e7791c2c..4e7b905c5 100644 --- a/src/utils/__tests__/tokens.test.ts +++ b/src/utils/__tests__/tokens.test.ts @@ -30,6 +30,18 @@ mock.module("src/services/tokenEstimation.ts", () => ({ countTokensViaHaikuFallback: async () => 0, })); +// Mock slowOperations to avoid bun:bundle import +mock.module('src/utils/slowOperations.ts', () => ({ + jsonStringify: JSON.stringify, + jsonParse: JSON.parse, + slowLogging: { enabled: false }, + clone: (v: any) => structuredClone(v), + cloneDeep: (v: any) => structuredClone(v), + callerFrame: () => '', + SLOW_OPERATION_THRESHOLD_MS: 100, + writeFileSync_DEPRECATED: () => {}, +})) + const { getTokenCountFromUsage, getTokenUsage, diff --git a/src/utils/advisor.ts b/src/utils/advisor.ts index 54a2dfb30..a151f9ae6 100644 --- a/src/utils/advisor.ts +++ b/src/utils/advisor.ts @@ -89,6 +89,7 @@ export function getExperimentAdvisorModels(): export function modelSupportsAdvisor(model: string): boolean { const m = model.toLowerCase() return ( + m.includes('opus-4-7') || m.includes('opus-4-6') || m.includes('sonnet-4-6') || process.env.USER_TYPE === 'ant' @@ -99,6 +100,7 @@ export function modelSupportsAdvisor(model: string): boolean { export function isValidAdvisorModel(model: string): boolean { const m = model.toLowerCase() return ( + m.includes('opus-4-7') || m.includes('opus-4-6') || m.includes('sonnet-4-6') || process.env.USER_TYPE === 'ant' diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index e0da3c1a3..4085c42b9 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -536,9 +536,25 @@ export type Attachment = } | { type: 'skill_discovery' - skills: { name: string; description: string; shortId?: string }[] + skills: { + name: string + description: string + shortId?: string + score?: number + autoLoaded?: boolean + content?: string + path?: string + }[] signal: DiscoverySignal source: 'native' | 'aki' | 'both' + gap?: { + key: string + status: 'pending' | 'draft' | 'active' + draftName?: string + draftPath?: string + activeName?: string + activePath?: string + } } | { type: 'queued_command' diff --git a/src/utils/attribution.ts b/src/utils/attribution.ts index d76291637..86863eed7 100644 --- a/src/utils/attribution.ts +++ b/src/utils/attribution.ts @@ -75,7 +75,7 @@ export function getAttributionTexts(): AttributionTexts { const modelName = isInternalModelRepoCached() || isKnownPublicModel ? getPublicModelName(model) - : 'Claude Opus 4.6' + : 'Claude Opus 4.7' const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})` const defaultCommit = `Co-Authored-By: ${modelName} ` diff --git a/src/utils/auth.ts b/src/utils/auth.ts index aa722cf07..9473b0d6b 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -514,7 +514,6 @@ async function _runAndCache( } catch (e) { if (epoch !== _apiKeyHelperEpoch) return ' ' const detail = e instanceof Error ? e.message : String(e) - // biome-ignore lint/suspicious/noConsole: user-configured script failed; must be visible without --debug console.error(chalk.red(`apiKeyHelper failed: ${detail}`)) logForDebugging(`Error getting API key from apiKeyHelper: ${detail}`, { level: 'error', @@ -690,7 +689,6 @@ export function refreshAwsAuth(awsAuthRefresh: string): Promise { : chalk.red( 'Error running awsAuthRefresh (in settings or ~/.claude.json):', ) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(message) authStatusManager.endAuthentication(false) void resolve(false) @@ -769,10 +767,8 @@ async function getAwsCredsFromCredentialExport(): Promise<{ 'Error getting AWS credentials from awsCredentialExport (in settings or ~/.claude.json):', ) if (e instanceof Error) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(message, e.message) } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(message, e) } return null @@ -958,7 +954,6 @@ export function refreshGcpAuth(gcpAuthRefresh: string): Promise { : chalk.red( 'Error running gcpAuthRefresh (in settings or ~/.claude.json):', ) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(message) authStatusManager.endAuthentication(false) void resolve(false) @@ -1779,6 +1774,7 @@ export function getOtelHeadersFromHelper(): Record { const debounceMs = parseInt( process.env.CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS || DEFAULT_OTEL_HEADERS_DEBOUNCE_MS.toString(), + 10, ) if ( cachedOtelHeaders && diff --git a/src/utils/autoUpdater.ts b/src/utils/autoUpdater.ts index 2a5fc6f97..af866eec6 100644 --- a/src/utils/autoUpdater.ts +++ b/src/utils/autoUpdater.ts @@ -81,7 +81,6 @@ export async function assertMinVersion(): Promise { versionConfig.minVersion && lt(MACRO.VERSION, versionConfig.minVersion) ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(` It looks like your version of Claude Code (${MACRO.VERSION}) needs an update. A newer version (${versionConfig.minVersion} or higher) is required to continue. @@ -478,7 +477,6 @@ export async function installGlobalPackage( currentVersion: MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(` Error: Windows NPM detected in WSL diff --git a/src/utils/bash/ShellSnapshot.ts b/src/utils/bash/ShellSnapshot.ts index d26f052cb..8d91ba454 100644 --- a/src/utils/bash/ShellSnapshot.ts +++ b/src/utils/bash/ShellSnapshot.ts @@ -421,6 +421,7 @@ export const createAndSaveSnapshot = async ( logForDebugging(`Creating shell snapshot for ${shellType} (${binShell})`) + // biome-ignore lint/suspicious/noAsyncPromiseExecutor: async needed for sequential awaits inside executor return new Promise(async resolve => { try { const configFile = getConfigFile(binShell) diff --git a/src/utils/bash/ast.ts b/src/utils/bash/ast.ts index fc2eca88a..900b15222 100644 --- a/src/utils/bash/ast.ts +++ b/src/utils/bash/ast.ts @@ -251,6 +251,7 @@ const BRACE_EXPANSION_RE = /\{[^{}\s]*(,|\.\.)[^{}\s]*\}/ * word boundaries. */ // eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control character detection regex const CONTROL_CHAR_RE = /[\x00-\x08\x0B-\x1F\x7F]/ /** @@ -1899,6 +1900,7 @@ function walkVariableAssignment( return { kind: 'too-complex', reason: + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${VAR} is bash syntax documentation, not a JS template literal 'PS4 value outside safe charset — only ${VAR} refs and [A-Za-z0-9 _+:.=/[]-] allowed', nodeType: 'variable_assignment', } diff --git a/src/utils/commitAttribution.ts b/src/utils/commitAttribution.ts index 6cf8c4d03..fdd79cbce 100644 --- a/src/utils/commitAttribution.ts +++ b/src/utils/commitAttribution.ts @@ -153,6 +153,7 @@ export function sanitizeSurfaceKey(surfaceKey: string): string { */ export function sanitizeModelName(shortName: string): string { // Map internal variants to public equivalents based on model family + if (shortName.includes('opus-4-7')) return 'claude-opus-4-7' if (shortName.includes('opus-4-6')) return 'claude-opus-4-6' if (shortName.includes('opus-4-5')) return 'claude-opus-4-5' if (shortName.includes('opus-4-1')) return 'claude-opus-4-1' diff --git a/src/utils/config.ts b/src/utils/config.ts index 4707feaaa..4167c70c5 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -525,8 +525,8 @@ export type GlobalConfig = { // Permission explainer configuration permissionExplainerEnabled?: boolean // Enable Haiku-generated explanations for permission requests (default: true) - // Teammate spawn mode: 'auto' | 'tmux' | 'in-process' - teammateMode?: 'auto' | 'tmux' | 'in-process' // How to spawn teammates (default: 'auto') + // Teammate spawn mode: 'auto' | 'tmux' | 'windows-terminal' | 'in-process' + teammateMode?: 'auto' | 'tmux' | 'windows-terminal' | 'in-process' // How to spawn teammates (default: 'auto') // Model for new teammates when the tool call doesn't pass one. // undefined = hardcoded Opus (backward-compat); null = leader's model; string = model alias/ID. teammateDefaultModel?: string | null diff --git a/src/utils/context.ts b/src/utils/context.ts index 51ea548d2..1c0680bd5 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -46,7 +46,11 @@ export function modelSupports1M(model: string): boolean { return false } const canonical = getCanonicalName(model) - return canonical.includes('claude-sonnet-4') || canonical.includes('opus-4-6') + return ( + canonical.includes('claude-sonnet-4') || + canonical.includes('opus-4-6') || + canonical.includes('opus-4-7') + ) } export function getContextWindowForModel( @@ -171,7 +175,10 @@ export function getModelMaxOutputTokens(model: string): { const m = getCanonicalName(model) - if (m.includes('opus-4-6')) { + if (m.includes('opus-4-7')) { + defaultTokens = 64_000 + upperLimit = 128_000 + } else if (m.includes('opus-4-6')) { defaultTokens = 64_000 upperLimit = 128_000 } else if (m.includes('sonnet-4-6')) { diff --git a/src/utils/deepLink/__tests__/protocolHandler.test.ts b/src/utils/deepLink/__tests__/protocolHandler.test.ts new file mode 100644 index 000000000..5987786d8 --- /dev/null +++ b/src/utils/deepLink/__tests__/protocolHandler.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' + +const mockParseDeepLink = mock((uri: string) => { + if (uri === null || uri === undefined || uri === 'bad-uri') { + throw new Error('invalid deep link') + } + return { query: 'hello', cwd: 'E:/Source_code/Claude-code-bast-test' } +}) +const mockLaunchInTerminal = mock(async () => true) + +mock.module('../parseDeepLink.js', () => ({ + parseDeepLink: mockParseDeepLink, +})) +mock.module('../registerProtocol.js', () => ({ + MACOS_BUNDLE_ID: 'com.anthropic.claude-code-url-handler', +})) +mock.module('../terminalLauncher.js', () => ({ + launchInTerminal: mockLaunchInTerminal, +})) +mock.module('../banner.js', () => ({ + readLastFetchTime: async () => undefined, + buildDeepLinkBanner: () => '', +})) +mock.module('../../githubRepoPathMapping.js', () => ({ + updateGithubRepoPathMapping: async () => {}, + getKnownPathsForRepo: () => [], + filterExistingPaths: async () => [], + validateRepoAtPath: async () => false, + removePathFromRepo: () => {}, +})) + +const { handleDeepLinkUri, handleUrlSchemeLaunch } = await import( + '../protocolHandler.js' +) + +const originalBundleId = process.env.__CFBundleIdentifier +const originalUrlEvent = process.env.CLAUDE_CODE_URL_EVENT + +beforeEach(() => { + mockParseDeepLink.mockClear() + mockLaunchInTerminal.mockClear() + process.env.__CFBundleIdentifier = undefined + delete process.env.CLAUDE_CODE_URL_EVENT +}) + +afterEach(() => { + process.env.__CFBundleIdentifier = originalBundleId + if (originalUrlEvent === undefined) { + delete process.env.CLAUDE_CODE_URL_EVENT + } else { + process.env.CLAUDE_CODE_URL_EVENT = originalUrlEvent + } +}) + +describe('handleUrlSchemeLaunch', () => { + test('returns null without calling url-handler-napi when bundle id does not match', async () => { + process.env.__CFBundleIdentifier = 'other.bundle' + + await expect(handleUrlSchemeLaunch()).resolves.toBeNull() + expect(mockParseDeepLink).not.toHaveBeenCalled() + }) + + test('returns null for a matching bundle id when no URL event arrives', async () => { + process.env.__CFBundleIdentifier = 'com.anthropic.claude-code-url-handler' + + await expect(handleUrlSchemeLaunch()).resolves.toBeNull() + expect(mockParseDeepLink).not.toHaveBeenCalled() + }) + + test('handles a URL event after waiting for url-handler-napi', async () => { + process.env.__CFBundleIdentifier = 'com.anthropic.claude-code-url-handler' + process.env.CLAUDE_CODE_URL_EVENT = 'claude-cli://prompt?q=hello' + + await expect(handleUrlSchemeLaunch()).resolves.toBe(0) + expect(mockParseDeepLink).toHaveBeenCalledWith( + 'claude-cli://prompt?q=hello', + ) + }) +}) + +describe('handleDeepLinkUri', () => { + test('returns 1 when parsing fails', async () => { + await expect(handleDeepLinkUri('bad-uri')).resolves.toBe(1) + expect(mockLaunchInTerminal).not.toHaveBeenCalled() + }) + + test('returns 0 when parsing succeeds and terminal launch succeeds', async () => { + await expect( + handleDeepLinkUri('claude-cli://prompt?q=hello'), + ).resolves.toBe(0) + expect(mockLaunchInTerminal).toHaveBeenCalled() + }) +}) diff --git a/src/utils/deepLink/protocolHandler.ts b/src/utils/deepLink/protocolHandler.ts index 511754dc7..3fb3733a9 100644 --- a/src/utils/deepLink/protocolHandler.ts +++ b/src/utils/deepLink/protocolHandler.ts @@ -94,11 +94,13 @@ export async function handleUrlSchemeLaunch(): Promise { try { const { waitForUrlEvent } = await import('url-handler-napi') - const url = (waitForUrlEvent as any)(5000) + const url = await ( + waitForUrlEvent as (timeoutMs?: number) => Promise + )(5000) if (!url) { return null } - return await handleDeepLinkUri(await url as string) + return await handleDeepLinkUri(url) } catch { // NAPI module not available, or handleDeepLinkUri rejected — not a URL launch return null diff --git a/src/utils/extraUsage.ts b/src/utils/extraUsage.ts index b09968416..79165e1c6 100644 --- a/src/utils/extraUsage.ts +++ b/src/utils/extraUsage.ts @@ -14,7 +14,8 @@ export function isBilledAsExtraUsage( .toLowerCase() .replace(/\[1m\]$/, '') .trim() - const isOpus46 = m === 'opus' || m.includes('opus-4-6') + const isOpus46 = + m === 'opus' || m.includes('opus-4-6') || m.includes('opus-4-7') const isSonnet46 = m === 'sonnet' || m.includes('sonnet-4-6') if (isOpus46 && isOpus1mMerged) return false diff --git a/src/utils/fastMode.ts b/src/utils/fastMode.ts index 98de3ee67..4ca17f833 100644 --- a/src/utils/fastMode.ts +++ b/src/utils/fastMode.ts @@ -140,7 +140,7 @@ export function getFastModeUnavailableReason(): string | null { } // @[MODEL LAUNCH]: Update supported Fast Mode models. -export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.6' +export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.7' export function getFastModeModel(): string { return 'opus' + (isOpus1mMergeEnabled() ? '[1m]' : '') @@ -172,7 +172,10 @@ export function isFastModeSupportedByModel( } const model = modelSetting ?? getDefaultMainLoopModelSetting() const parsedModel = parseUserSpecifiedModel(model) - return parsedModel.toLowerCase().includes('opus-4-6') + return ( + parsedModel.toLowerCase().includes('opus-4-7') || + parsedModel.toLowerCase().includes('opus-4-6') + ) } // --- Fast mode runtime state --- diff --git a/src/utils/fileHistory.ts b/src/utils/fileHistory.ts index 4e227d997..79eb0958b 100644 --- a/src/utils/fileHistory.ts +++ b/src/utils/fileHistory.ts @@ -1109,7 +1109,6 @@ async function readFileAsyncOrNull(path: string): Promise { const ENABLE_DUMP_STATE = false function maybeDumpStateForDebug(state: FileHistoryState): void { if (ENABLE_DUMP_STATE) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(inspect(state, false, 5)) } } diff --git a/src/utils/frontmatterParser.ts b/src/utils/frontmatterParser.ts index 9f5deba94..13343432d 100644 --- a/src/utils/frontmatterParser.ts +++ b/src/utils/frontmatterParser.ts @@ -35,7 +35,7 @@ export type FrontmatterData = { // Values are arrays of matcher configurations with hooks // Validated by HooksSchema in loadSkillsDir.ts hooks?: HooksSettings | null - // Effort level for agents (e.g., 'low', 'medium', 'high', 'max', or an integer) + // Effort level for agents (e.g., 'low', 'medium', 'high', 'xhigh', 'max', or an integer) // Controls the thinking effort used by the agent's model effort?: string | null // Execution context for skills: 'inline' (default) or 'fork' (run as sub-agent) diff --git a/src/utils/generators.ts b/src/utils/generators.ts index 9070a34ce..69d1088c2 100644 --- a/src/utils/generators.ts +++ b/src/utils/generators.ts @@ -22,7 +22,9 @@ export async function returnValue( } type QueuedGenerator = { + // biome-ignore lint/suspicious/noConfusingVoidType: void matches AsyncGenerator return type done: boolean | void + // biome-ignore lint/suspicious/noConfusingVoidType: void matches AsyncGenerator yield type value: A | void generator: AsyncGenerator promise: Promise> diff --git a/src/utils/hooks/execHttpHook.ts b/src/utils/hooks/execHttpHook.ts index b1e582266..aedee7371 100644 --- a/src/utils/hooks/execHttpHook.ts +++ b/src/utils/hooks/execHttpHook.ts @@ -75,6 +75,7 @@ function urlMatchesPattern(url: string, pattern: string): boolean { */ function sanitizeHeaderValue(value: string): string { // eslint-disable-next-line no-control-regex + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control character sanitization return value.replace(/[\r\n\x00]/g, '') } diff --git a/src/utils/ide.ts b/src/utils/ide.ts index fe6d55d7b..3d2aaeef1 100644 --- a/src/utils/ide.ts +++ b/src/utils/ide.ts @@ -379,7 +379,7 @@ async function readIdeLockfile(path: string): Promise { return { workspaceFolders, - port: parseInt(port), + port: parseInt(port, 10), pid, ideName, useWebSocket, @@ -669,7 +669,7 @@ export async function detectIDEs( try { // Get the CLAUDE_CODE_SSE_PORT if set const ssePort = process.env.CLAUDE_CODE_SSE_PORT - const envPort = ssePort ? parseInt(ssePort) : null + const envPort = ssePort ? parseInt(ssePort, 10) : null // Get the current working directory, normalized to NFC for consistent // comparison. macOS returns NFD paths (decomposed Unicode), while IDEs @@ -1006,7 +1006,7 @@ function getVSCodeIDECommandByParentProcess(): string | null { if (!ppidStr) { break } - pid = parseInt(ppidStr.trim()) + pid = parseInt(ppidStr.trim(), 10) } return null diff --git a/src/utils/log.ts b/src/utils/log.ts index da9b1d66d..f69d8a436 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -158,7 +158,6 @@ const isHardFailMode = memoize((): boolean => { export function logError(error: unknown): void { const err = toError(error) if (feature('HARD_FAIL') && isHardFailMode()) { - // biome-ignore lint/suspicious/noConsole:: intentional crash output console.error('[HARD FAIL] logError called with:', err.stack || err.message) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) diff --git a/src/utils/messages.ts b/src/utils/messages.ts index ab1bd122a..95aa8136c 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -3555,14 +3555,40 @@ Read the team config to discover your teammates' names. Check the task list peri // be gated, but this pattern can — same approach as teammate_mailbox above. if (feature('EXPERIMENTAL_SKILL_SEARCH')) { if (attachment.type === 'skill_discovery') { - if (attachment.skills.length === 0) return [] - const lines = attachment.skills.map(s => `- ${s.name}: ${s.description}`) + if (attachment.skills.length === 0 && !attachment.gap) return [] + const loaded = attachment.skills.filter(s => s.autoLoaded && s.content) + const recommended = attachment.skills.filter(s => !s.autoLoaded) + const loadedSections = loaded.map( + s => + `<${COMMAND_NAME_TAG}>${s.name}\n` + + `\n${s.content}\n`, + ) + const recommendationLines = recommended.map( + s => `- ${s.name}: ${s.description}`, + ) + const gapText = attachment.gap + ? [ + 'No high-confidence active skill was auto-loaded for this request.', + attachment.gap.activePath + ? `A learned skill was promoted for future turns: ${attachment.gap.activeName} (${attachment.gap.activePath}).` + : attachment.gap.draftPath + ? `A draft learned skill candidate was created: ${attachment.gap.draftName} (${attachment.gap.draftPath}).` + : `The skill gap was recorded for future learning: ${attachment.gap.key}.`, + ].join('\n') + : '' return wrapMessagesInSystemReminder([ createUserMessage({ - content: - `Skills relevant to your task:\n\n${lines.join('\n')}\n\n` + - `These skills encode project-specific conventions. ` + - `Invoke via Skill("") for complete instructions.`, + content: [ + loadedSections.length > 0 + ? `The following skills are auto-loaded for this task. Apply their instructions now; do not call Skill("") again for these loaded skills.\n\n${loadedSections.join('\n\n')}` + : '', + recommendationLines.length > 0 + ? `Additional relevant skills were found but not auto-loaded:\n\n${recommendationLines.join('\n')}\n\nInvoke via Skill("") only if you need their complete instructions.` + : '', + gapText, + ] + .filter(Boolean) + .join('\n\n'), isMeta: true, }), ]) @@ -3570,7 +3596,6 @@ Read the team config to discover your teammates' names. Check the task list peri } // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/team_context/skill_discovery/bagel_console handled above - // biome-ignore lint/nursery/useExhaustiveSwitchCases: teammate_mailbox/team_context/max_turns_reached/skill_discovery/bagel_console handled above, can't add case for dead code elimination switch (attachment.type) { case 'directory': { return wrapMessagesInSystemReminder([ diff --git a/src/utils/modifiers.ts b/src/utils/modifiers.ts index 08bde4bcd..1b379530f 100644 --- a/src/utils/modifiers.ts +++ b/src/utils/modifiers.ts @@ -11,14 +11,7 @@ export function prewarmModifiers(): void { return } prewarmed = true - // Load module in background - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { prewarm } = require('modifiers-napi') as { prewarm: () => void } - prewarm() - } catch { - // Ignore errors during prewarm - } + void import('modifiers-napi').then(({ prewarm }) => prewarm()).catch(() => {}) } /** @@ -28,9 +21,12 @@ export function isModifierPressed(modifier: ModifierKey): boolean { if (process.platform !== 'darwin') { return false } - // Dynamic import to avoid loading native module at top level - const { isModifierPressed: nativeIsModifierPressed } = + try { // eslint-disable-next-line @typescript-eslint/no-require-imports - require('modifiers-napi') as { isModifierPressed: (m: string) => boolean } - return nativeIsModifierPressed(modifier) + const { isModifierPressed: nativeIsModifierPressed } = + require('modifiers-napi') as { isModifierPressed: (m: string) => boolean } + return nativeIsModifierPressed(modifier) + } catch { + return false + } } diff --git a/src/utils/permissions/permissionSetup.ts b/src/utils/permissions/permissionSetup.ts index 986bb7b98..711107745 100644 --- a/src/utils/permissions/permissionSetup.ts +++ b/src/utils/permissions/permissionSetup.ts @@ -799,6 +799,10 @@ export function initialPermissionModeFromCLI({ result = { mode: 'default', notification } } + if (!result) { + result = { mode: 'default', notification } + } + if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') { autoModeStateModule?.setAutoModeActive(true) } @@ -923,7 +927,6 @@ export async function initializeToolPermissionContext({ }) } - // Bypass permissions mode is available to all users const isBypassPermissionsModeAvailable = true const settings = getSettings_DEPRECATED() || {} @@ -1061,54 +1064,131 @@ export function getAutoModeUnavailableNotification( * kicking the user out of a mode they've already left during the await. */ export async function verifyAutoModeGateAccess( - _currentContext: ToolPermissionContext, + currentContext: ToolPermissionContext, // Runtime AppState.fastMode — passed from callers with AppState access so // the disableFastMode circuit breaker reads current state, not stale // settings.fastMode (which is intentionally sticky across /model auto- // downgrades). Optional for callers without AppState (e.g. SDK init paths). fastMode?: boolean, ): Promise { - // Only fast-mode circuit breaker remains. All other gates (GrowthBook, - // settings, model support, opt-in) have been removed. + // Auto-mode config — runs in ALL builds (circuit breaker, carousel, kick-out) + // Fresh read of tengu_auto_mode_config.enabled — this async check runs once + // after GrowthBook initialization and is the authoritative source for + // isAutoModeAvailable. The sync startup path uses stale cache; this + // corrects it. Circuit breaker (enabled==='disabled') takes effect here. const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ enabled?: AutoModeEnabledState disableFastMode?: boolean }>('tengu_auto_mode_config', {}) + const enabledState = parseAutoModeEnabledState(autoModeConfig?.enabled) + const disabledBySettings = isAutoModeDisabledBySettings() + // Treat settings-disable the same as GrowthBook 'disabled' for circuit-breaker + // semantics — blocks SDK/explicit re-entry via isAutoModeGateEnabled(). + autoModeStateModule?.setAutoModeCircuitBroken( + enabledState === 'disabled' || disabledBySettings, + ) + // Carousel availability: not circuit-broken, not disabled-by-settings, + // model supports it, disableFastMode breaker not firing, and (enabled or opted-in) const mainModel = getMainLoopModel() + // Temp circuit breaker: tengu_auto_mode_config.disableFastMode blocks auto + // mode when fast mode is on. Checks runtime AppState.fastMode (if provided) + // and, for ants, model name '-fast' substring (ant-internal fast models + // like capybara-v2-fast[1m] encode speed in the model ID itself). + // Remove once auto+fast mode interaction is validated. const disableFastModeBreakerFires = !!autoModeConfig?.disableFastMode && (!!fastMode || (process.env.USER_TYPE === 'ant' && mainModel.toLowerCase().includes('-fast'))) - - // If fast-mode breaker fires, circuit-break auto mode - autoModeStateModule?.setAutoModeCircuitBroken(disableFastModeBreakerFires) - + const modelSupported = + modelSupportsAutoMode(mainModel) && !disableFastModeBreakerFires + let carouselAvailable = false + if (enabledState !== 'disabled' && !disabledBySettings && modelSupported) { + carouselAvailable = + enabledState === 'enabled' || hasAutoModeOptInAnySource() + } + // canEnterAuto gates explicit entry (--permission-mode auto, defaultMode: auto) + // — explicit entry IS an opt-in, so we only block on circuit breaker + settings + model + const canEnterAuto = + enabledState !== 'disabled' && !disabledBySettings && modelSupported logForDebugging( - `[auto-mode] verifyAutoModeGateAccess: disableFastModeBreakerFires=${disableFastModeBreakerFires}`, + `[auto-mode] verifyAutoModeGateAccess: enabledState=${enabledState} disabledBySettings=${disabledBySettings} model=${mainModel} modelSupported=${modelSupported} disableFastModeBreakerFires=${disableFastModeBreakerFires} carouselAvailable=${carouselAvailable} canEnterAuto=${canEnterAuto}`, ) - if (!disableFastModeBreakerFires) { - // Auto mode available — no kick-out needed - return { updateContext: ctx => ctx } + // Capture CLI-flag intent now (doesn't depend on context). + const autoModeFlagCli = autoModeStateModule?.getAutoModeFlagCli() ?? false + + // Return a transform function that re-evaluates context-dependent conditions + // against the CURRENT context at setAppState time. The async GrowthBook + // results above (canEnterAuto, carouselAvailable, enabledState, reason) are + // closure-captured — those don't depend on context. But mode, prePlanMode, + // and isAutoModeAvailable checks MUST use the fresh ctx or a mid-await + // shift-tab gets reverted (or worse, the user stays in auto despite the + // circuit breaker if they entered auto DURING the await — which is possible + // because setAutoModeCircuitBroken above runs AFTER the await). + const setAvailable = ( + ctx: ToolPermissionContext, + available: boolean, + ): ToolPermissionContext => { + if (ctx.isAutoModeAvailable !== available) { + logForDebugging( + `[auto-mode] verifyAutoModeGateAccess setAvailable: ${ctx.isAutoModeAvailable} -> ${available}`, + ) + } + return ctx.isAutoModeAvailable === available + ? ctx + : { ...ctx, isAutoModeAvailable: available } } - // Fast-mode breaker fired — kick out of auto if currently in it - const notification = getAutoModeUnavailableNotification('circuit-breaker') + if (canEnterAuto) { + return { updateContext: ctx => setAvailable(ctx, carouselAvailable) } + } + // Gate is off or circuit-broken — determine reason (context-independent). + let reason: AutoModeUnavailableReason + if (disabledBySettings) { + reason = 'settings' + logForDebugging('auto mode disabled: disableAutoMode in settings', { + level: 'warn', + }) + } else if (enabledState === 'disabled') { + reason = 'circuit-breaker' + logForDebugging( + 'auto mode disabled: tengu_auto_mode_config.enabled === "disabled" (circuit breaker)', + { level: 'warn' }, + ) + } else { + reason = 'model' + logForDebugging( + `auto mode disabled: model ${getMainLoopModel()} does not support auto mode`, + { level: 'warn' }, + ) + } + const notification = getAutoModeUnavailableNotification(reason) + + // Unified kick-out transform. Re-checks the FRESH ctx and only fires + // side effects (setAutoModeActive(false), setNeedsAutoModeExitAttachment) + // when the kick-out actually applies. This keeps autoModeActive in sync + // with toolPermissionContext.mode even if the user changed modes during + // the await: if they already left auto on their own, handleCycleMode + // already deactivated the classifier and we don't fire again; if they + // ENTERED auto during the await (possible before setAutoModeCircuitBroken + // landed), we kick them out here. const kickOutOfAutoIfNeeded = ( ctx: ToolPermissionContext, ): ToolPermissionContext => { const inAuto = ctx.mode === 'auto' logForDebugging( - `[auto-mode] kickOutOfAutoIfNeeded (fast-mode): ctx.mode=${ctx.mode}`, + `[auto-mode] kickOutOfAutoIfNeeded applying: ctx.mode=${ctx.mode} ctx.prePlanMode=${ctx.prePlanMode} reason=${reason}`, ) + // Plan mode with auto active: either from prePlanMode='auto' (entered + // from auto) or from opt-in (strippedDangerousRules present). const inPlanWithAutoActive = ctx.mode === 'plan' && (ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules) if (!inAuto && !inPlanWithAutoActive) { - return { ...ctx, isAutoModeAvailable: false } + return setAvailable(ctx, false) } if (inAuto) { autoModeStateModule?.setAutoModeActive(false) @@ -1122,6 +1202,8 @@ export async function verifyAutoModeGateAccess( isAutoModeAvailable: false, } } + // Plan with auto active: deactivate auto, restore permissions, defuse + // prePlanMode so ExitPlanMode goes to default. autoModeStateModule?.setAutoModeActive(false) setNeedsAutoModeExitAttachment(true) return { @@ -1131,23 +1213,62 @@ export async function verifyAutoModeGateAccess( } } - return { updateContext: kickOutOfAutoIfNeeded, notification } + // Notification decisions use the stale context — that's OK: we're deciding + // WHETHER to notify based on what the user WAS doing when this check started. + // (Side effects and mode mutation are decided inside the transform above, + // against the fresh ctx.) + const wasInAuto = currentContext.mode === 'auto' + // Auto was used during plan: entered from auto or opt-in auto active + const autoActiveDuringPlan = + currentContext.mode === 'plan' && + (currentContext.prePlanMode === 'auto' || + !!currentContext.strippedDangerousRules) + const wantedAuto = wasInAuto || autoActiveDuringPlan || autoModeFlagCli + + if (!wantedAuto) { + // User didn't want auto at call time — no notification. But still apply + // the full kick-out transform: if they shift-tabbed INTO auto during the + // await (before setAutoModeCircuitBroken landed), we need to evict them. + return { updateContext: kickOutOfAutoIfNeeded } + } + + if (wasInAuto || autoActiveDuringPlan) { + // User was in auto or had auto active during plan — kick out + notify. + return { updateContext: kickOutOfAutoIfNeeded, notification } + } + + // autoModeFlagCli only: defaultMode was auto but sync check rejected it. + // Suppress notification if isAutoModeAvailable is already false (already + // notified on a prior check; prevents repeat notifications on successive + // unsupported-model switches). + return { + updateContext: kickOutOfAutoIfNeeded, + notification: currentContext.isAutoModeAvailable ? notification : undefined, + } } /** - * Bypass permissions is always available — no remote gate check needed. + * Core logic to check if bypassPermissions should be disabled based on Statsig gate */ export function shouldDisableBypassPermissions(): Promise { - return Promise.resolve(false) + return checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode') +} + +function isAutoModeDisabledBySettings(): boolean { + const settings = getSettings_DEPRECATED() || {} + return ( + (settings as { disableAutoMode?: 'disable' }).disableAutoMode === + 'disable' || + (settings.permissions as { disableAutoMode?: 'disable' } | undefined) + ?.disableAutoMode === 'disable' + ) } /** - * Checks if auto mode can be entered: only fast-mode circuit breaker remains. - * Synchronous. + * Checks if auto mode can be entered: circuit breaker is not active and settings + * have not disabled it. Synchronous. */ export function isAutoModeGateEnabled(): boolean { - // Auto mode is available to all users — only fast-mode circuit breaker remains - if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false return true } @@ -1156,9 +1277,11 @@ export function isAutoModeGateEnabled(): boolean { * Synchronous — uses state populated by verifyAutoModeGateAccess. */ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null { + if (isAutoModeDisabledBySettings()) return 'settings' if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) { return 'circuit-breaker' } + if (!modelSupportsAutoMode(getMainLoopModel())) return 'model' return null } @@ -1172,7 +1295,11 @@ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null */ export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in' -const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = 'enabled' +const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = feature( + 'TRANSCRIPT_CLASSIFIER', +) + ? 'enabled' + : 'disabled' function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState { if (value === 'enabled' || value === 'disabled' || value === 'opt-in') { @@ -1222,15 +1349,27 @@ export function getAutoModeEnabledStateIfCached(): * dialog or by IDE/Desktop settings toggle) */ export function hasAutoModeOptInAnySource(): boolean { - return true + if (autoModeStateModule?.getAutoModeFlagCli() ?? false) return true + return hasAutoModeOptIn() } /** * Checks if bypassPermissions mode is currently disabled by Statsig gate or settings. - * Always returns false — bypass is available to all users. + * This is a synchronous version that uses cached Statsig values. */ export function isBypassPermissionsModeDisabled(): boolean { - return false + const growthBookDisableBypassPermissionsMode = + checkStatsigFeatureGate_CACHED_MAY_BE_STALE( + 'tengu_disable_bypass_permissions_mode', + ) + const settings = getSettings_DEPRECATED() || {} + const settingsDisableBypassPermissionsMode = + settings.permissions?.disableBypassPermissionsMode === 'disable' + + return ( + growthBookDisableBypassPermissionsMode || + settingsDisableBypassPermissionsMode + ) } /** @@ -1255,12 +1394,29 @@ export function createDisabledBypassPermissionsContext( } /** - * No-op — bypass permissions is always available, no remote gate check needed. + * Asynchronously checks if the bypassPermissions mode should be disabled based on Statsig gate + * and returns an updated toolPermissionContext if needed */ export async function checkAndDisableBypassPermissions( - _currentContext: ToolPermissionContext, + currentContext: ToolPermissionContext, ): Promise { - // Bypass permissions is always available — no gate check needed + // Only proceed if bypassPermissions mode is available + if (!currentContext.isBypassPermissionsModeAvailable) { + return + } + + const shouldDisable = await shouldDisableBypassPermissions() + if (!shouldDisable) { + return + } + + // Gate is enabled, need to disable bypassPermissions mode + logForDebugging( + 'bypassPermissions mode is being disabled by Statsig gate (async check)', + { level: 'warn' }, + ) + + void gracefulShutdown(1, 'bypass_permissions_disabled') } export function isDefaultPermissionModeAuto(): boolean { @@ -1278,7 +1434,11 @@ export function isDefaultPermissionModeAuto(): boolean { */ export function shouldPlanUseAutoMode(): boolean { if (feature('TRANSCRIPT_CLASSIFIER')) { - return isAutoModeGateEnabled() && getUseAutoModeDuringPlan() + return ( + hasAutoModeOptIn() && + isAutoModeGateEnabled() && + getUseAutoModeDuringPlan() + ) } return false } diff --git a/src/utils/pipeStatus.ts b/src/utils/pipeStatus.ts new file mode 100644 index 000000000..7bc38dd45 --- /dev/null +++ b/src/utils/pipeStatus.ts @@ -0,0 +1,32 @@ +import type { PipeRegistry } from './pipeRegistry.js' +import { readRegistry } from './pipeRegistry.js' + +export async function formatPipeRegistryStatus(): Promise { + return formatPipeRegistry(await readRegistry()) +} + +export function formatPipeRegistry(registry: PipeRegistry): string { + const lines = [ + `Pipe registry: ${registry.main ? 1 : 0} main, ${registry.subs.length} sub(s)`, + ] + if (registry.mainMachineId) { + lines.push(` main_machine=${registry.mainMachineId.slice(0, 8)}...`) + } + if (registry.main) { + lines.push( + ` [main] ${registry.main.pipeName} pid=${registry.main.pid} host=${registry.main.hostname} tcp=${registry.main.tcpPort ?? 'none'}`, + ) + } + for (const sub of registry.subs.slice(0, 10)) { + lines.push( + ` [sub-${sub.subIndex}] ${sub.pipeName} pid=${sub.pid} host=${sub.hostname} bound=${sub.boundToMain ?? 'none'} tcp=${sub.tcpPort ?? 'none'}`, + ) + } + if (!registry.main && registry.subs.length === 0) { + lines.push(' none') + } + if (registry.subs.length > 10) { + lines.push(` ... ${registry.subs.length - 10} more sub pipe(s)`) + } + return lines.join('\n') +} diff --git a/src/utils/plugins/schemas.ts b/src/utils/plugins/schemas.ts index b92fb80e9..c63975522 100644 --- a/src/utils/plugins/schemas.ts +++ b/src/utils/plugins/schemas.ts @@ -645,6 +645,7 @@ const PluginManifestUserConfigSchema = lazySchema(() => .describe( 'User-configurable values this plugin needs. Prompted at enable time. ' + 'Non-sensitive values saved to settings.json; sensitive values to secure storage ' + + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${user_config.KEY} is plugin config syntax documentation, not a JS template literal '(macOS keychain or .credentials.json). Available as ${user_config.KEY} in ' + 'MCP/LSP server config, hook commands, and (non-sensitive only) skill/agent content. ' + 'Note: sensitive values share a single keychain entry with OAuth tokens — keep ' + @@ -690,6 +691,7 @@ const PluginManifestChannelsSchema = lazySchema(() => .optional() .describe( 'Fields to prompt the user for when enabling this plugin in assistant mode. ' + + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${user_config.KEY} is plugin config syntax documentation, not a JS template literal 'Saved values are substituted into ${user_config.KEY} references in the mcpServers env.', ), }) diff --git a/src/utils/powershell/parser.ts b/src/utils/powershell/parser.ts index a815403b6..4dbb6af4a 100644 --- a/src/utils/powershell/parser.ts +++ b/src/utils/powershell/parser.ts @@ -1702,6 +1702,7 @@ export function getPipelineSegments( */ export function isNullRedirectionTarget(target: string): boolean { const t = target.trim().toLowerCase() + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${null} is PowerShell syntax, not a JS template literal return t === '$null' || t === '${null}' } diff --git a/src/utils/process.ts b/src/utils/process.ts index 10ec2271e..1ac0a2551 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -36,7 +36,6 @@ export function writeToStderr(data: string): void { // Write error to stderr and exit with code 1. Consolidates the // console.error + process.exit(1) pattern used in entrypoint fast-paths. export function exitWithError(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/utils/promptEditor.ts b/src/utils/promptEditor.ts index 0b0a65241..fa23b2818 100644 --- a/src/utils/promptEditor.ts +++ b/src/utils/promptEditor.ts @@ -114,7 +114,7 @@ function recollapsePastedContent( // Find pasted content in the edited text and re-collapse it for (const [id, content] of Object.entries(pastedContents)) { if (content.type === 'text') { - const pasteId = parseInt(id) + const pasteId = parseInt(id, 10) const contentStr = content.content // Check if this exact content exists in the edited prompt diff --git a/src/utils/remoteControlStatus.ts b/src/utils/remoteControlStatus.ts new file mode 100644 index 000000000..80dfc21c3 --- /dev/null +++ b/src/utils/remoteControlStatus.ts @@ -0,0 +1,23 @@ +import { + getBridgeAccessToken, + getBridgeBaseUrl, + isSelfHostedBridge, +} from '../bridge/bridgeConfig.js' + +export function formatRemoteControlLocalStatus(): string { + try { + const selfHosted = isSelfHostedBridge() + const token = getBridgeAccessToken() + return [ + `Remote Control: ${selfHosted ? 'self-hosted' : 'official'}`, + ` base_url=${getBridgeBaseUrl()}`, + ` token=${token ? 'present' : 'missing'}`, + ' entitlement=checked at remote-control startup', + ].join('\n') + } catch (error) { + return [ + 'Remote Control: unknown', + ` reason=${error instanceof Error ? error.message : String(error)}`, + ].join('\n') + } +} diff --git a/src/utils/remoteTriggerAudit.ts b/src/utils/remoteTriggerAudit.ts new file mode 100644 index 000000000..1376652ad --- /dev/null +++ b/src/utils/remoteTriggerAudit.ts @@ -0,0 +1,91 @@ +import { randomUUID } from 'crypto' +import { mkdir, readFile, appendFile } from 'fs/promises' +import { dirname, join } from 'path' +import { getProjectRoot } from '../bootstrap/state.js' + +const REMOTE_TRIGGER_AUDIT_REL = join('.claude', 'remote-trigger-audit.jsonl') +const MAX_AUDIT_RECORDS = 200 + +export type RemoteTriggerAuditRecord = { + auditId: string + action: string + triggerId?: string + ok: boolean + status?: number + error?: string + createdAt: number +} + +export function resolveRemoteTriggerAuditPath( + rootDir: string = getProjectRoot(), +): string { + return join(rootDir, REMOTE_TRIGGER_AUDIT_REL) +} + +export async function appendRemoteTriggerAuditRecord( + record: Omit & { + auditId?: string + createdAt?: number + }, + rootDir: string = getProjectRoot(), +): Promise { + const fullRecord: RemoteTriggerAuditRecord = { + auditId: record.auditId ?? randomUUID(), + action: record.action, + ...(record.triggerId ? { triggerId: record.triggerId } : {}), + ok: record.ok, + ...(record.status !== undefined ? { status: record.status } : {}), + ...(record.error ? { error: record.error } : {}), + createdAt: record.createdAt ?? Date.now(), + } + const path = resolveRemoteTriggerAuditPath(rootDir) + await mkdir(dirname(path), { recursive: true }) + await appendFile(path, `${JSON.stringify(fullRecord)}\n`, 'utf-8') + return fullRecord +} + +export async function listRemoteTriggerAuditRecords( + rootDir: string = getProjectRoot(), +): Promise { + let raw: string + try { + raw = await readFile(resolveRemoteTriggerAuditPath(rootDir), 'utf-8') + } catch { + return [] + } + const records: RemoteTriggerAuditRecord[] = [] + for (const line of raw.split('\n')) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) as Partial + if ( + parsed && + typeof parsed.auditId === 'string' && + typeof parsed.action === 'string' && + typeof parsed.ok === 'boolean' && + typeof parsed.createdAt === 'number' + ) { + records.push(parsed as RemoteTriggerAuditRecord) + } + } catch { + // Ignore malformed historical lines. + } + } + return records + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, MAX_AUDIT_RECORDS) +} + +export function formatRemoteTriggerAuditStatus( + records: RemoteTriggerAuditRecord[], +): string { + const failures = records.filter(r => !r.ok) + const latest = records[0] + return [ + `RemoteTrigger audit records: ${records.length}`, + `Failures: ${failures.length}`, + latest + ? `Latest: ${latest.action}${latest.triggerId ? ` ${latest.triggerId}` : ''} ${latest.ok ? 'ok' : 'failed'} (${new Date(latest.createdAt).toLocaleString()})` + : 'Latest: none', + ].join('\n') +} diff --git a/src/utils/secureStorage/keychainPrefetch.ts b/src/utils/secureStorage/keychainPrefetch.ts index 061c2b16c..426e547e8 100644 --- a/src/utils/secureStorage/keychainPrefetch.ts +++ b/src/utils/secureStorage/keychainPrefetch.ts @@ -52,7 +52,6 @@ function spawnSecurity(serviceName: string): Promise { // Exit 44 (entry not found) is a valid "no key" result and safe to // prime as null. But timeout (err.killed) means the keychain MAY have // a key we couldn't fetch — don't prime, let sync spawn retry. - // biome-ignore lint/nursery/noFloatingPromises: resolve() is not a floating promise resolve({ stdout: err ? null : stdout?.trim() || null, timedOut: Boolean(err && 'killed' in err && err.killed), diff --git a/src/utils/settings/mdm/rawRead.ts b/src/utils/settings/mdm/rawRead.ts index 18aeacafd..689e8f834 100644 --- a/src/utils/settings/mdm/rawRead.ts +++ b/src/utils/settings/mdm/rawRead.ts @@ -39,7 +39,6 @@ function execFilePromise( args, { encoding: 'utf-8', timeout: MDM_SUBPROCESS_TIMEOUT_MS }, (err, stdout) => { - // biome-ignore lint/nursery/noFloatingPromises: resolve() is not a floating promise resolve({ stdout: stdout ?? '', code: err ? 1 : 0 }) }, ) diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 127880c1a..37c78ce3f 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -710,8 +710,8 @@ export const SettingsSchema = lazySchema(() => effortLevel: z .enum( process.env.USER_TYPE === 'ant' - ? ['low', 'medium', 'high', 'max'] - : ['low', 'medium', 'high'], + ? ['low', 'medium', 'high', 'xhigh', 'max'] + : ['low', 'medium', 'high', 'xhigh'], ) .optional() .catch(undefined) diff --git a/src/utils/shell/prefix.ts b/src/utils/shell/prefix.ts index e97a37b76..3949878e5 100644 --- a/src/utils/shell/prefix.ts +++ b/src/utils/shell/prefix.ts @@ -203,7 +203,6 @@ async function getCommandPrefixImpl( if (nonInteractive) { process.stderr.write(jsonStringify({ level: 'warn', message }) + '\n') } else { - // biome-ignore lint/suspicious/noConsole: intentional warning console.warn(chalk.yellow(`⚠️ ${message}`)) } }, diff --git a/src/utils/slowOperations.ts b/src/utils/slowOperations.ts index 1cb454940..263d253cb 100644 --- a/src/utils/slowOperations.ts +++ b/src/utils/slowOperations.ts @@ -6,7 +6,6 @@ import { fsyncSync, openSync, } from 'fs' -// biome-ignore lint: This file IS the cloneDeep wrapper - it must import the original import lodashCloneDeep from 'lodash-es/cloneDeep.js' import { addSlowOperation } from '../bootstrap/state.js' import { logForDebugging } from './debug.js' @@ -132,6 +131,7 @@ function slowLoggingAnt( ..._values: unknown[] ): AntSlowLogger { // eslint-disable-next-line prefer-rest-params + // biome-ignore lint/complexity/noArguments: intentional use of arguments object for AntSlowLogger return new AntSlowLogger(arguments) } diff --git a/src/utils/stats.ts b/src/utils/stats.ts index d23e93e6f..59f769eeb 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -793,7 +793,7 @@ function processedStatsToClaudeCodeStats( hourEntries.length > 0 ? parseInt( hourEntries.reduce((max, [hour, count]) => - count > parseInt(max[1].toString()) ? [hour, count] : max, + count > parseInt(max[1].toString(), 10) ? [hour, count] : max, )[0], 10, ) diff --git a/src/utils/teamDiscovery.ts b/src/utils/teamDiscovery.ts index 454c142ee..e4b806695 100644 --- a/src/utils/teamDiscovery.ts +++ b/src/utils/teamDiscovery.ts @@ -5,7 +5,7 @@ * Used by the Teams UI in the footer to show team status. */ -import { isPaneBackend, type PaneBackendType } from './swarm/backends/types.js' +import { type BackendType } from './swarm/backends/types.js' import { readTeamFile } from './swarm/teamHelpers.js' export type TeamSummary = { @@ -28,7 +28,7 @@ export type TeammateStatus = { cwd: string worktreePath?: string isHidden?: boolean // Whether the pane is currently hidden from the swarm view - backendType?: PaneBackendType // The backend type used for this teammate + backendType?: BackendType // The backend type used for this teammate mode?: string // Current permission mode for this teammate } @@ -67,10 +67,7 @@ export function getTeammateStatuses(teamName: string): TeammateStatus[] { cwd: member.cwd, worktreePath: member.worktreePath, isHidden: hiddenPaneIds.has(member.tmuxPaneId), - backendType: - member.backendType && isPaneBackend(member.backendType) - ? member.backendType - : undefined, + backendType: member.backendType, mode: member.mode, }) } diff --git a/src/utils/teammate.ts b/src/utils/teammate.ts index d7baa81f2..f9bbdc01a 100644 --- a/src/utils/teammate.ts +++ b/src/utils/teammate.ts @@ -262,7 +262,6 @@ export function waitForTeammatesToBecomeIdle( const onIdle = (): void => { remaining-- if (remaining === 0) { - // biome-ignore lint/nursery/noFloatingPromises: resolve is a callback, not a Promise resolve() } } diff --git a/src/utils/telemetry/instrumentation.ts b/src/utils/telemetry/instrumentation.ts index 224f04815..fffe84def 100644 --- a/src/utils/telemetry/instrumentation.ts +++ b/src/utils/telemetry/instrumentation.ts @@ -132,6 +132,7 @@ async function getOtlpReaders() { const exportInterval = parseInt( process.env.OTEL_METRIC_EXPORT_INTERVAL || DEFAULT_METRICS_EXPORT_INTERVAL_MS.toString(), + 10, ) const exporters = [] @@ -527,6 +528,7 @@ export async function initializeTelemetry() { const shutdownTelemetry = async () => { const timeoutMs = parseInt( process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000', + 10, ) try { endInteractionSpan() @@ -589,6 +591,7 @@ export async function initializeTelemetry() { scheduledDelayMillis: parseInt( process.env.OTEL_LOGS_EXPORT_INTERVAL || DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(), + 10, ), }), ), @@ -635,6 +638,7 @@ export async function initializeTelemetry() { scheduledDelayMillis: parseInt( process.env.OTEL_TRACES_EXPORT_INTERVAL || DEFAULT_TRACES_EXPORT_INTERVAL_MS.toString(), + 10, ), }), ) @@ -654,6 +658,7 @@ export async function initializeTelemetry() { const shutdownTelemetry = async () => { const timeoutMs = parseInt( process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000', + 10, ) try { @@ -712,6 +717,7 @@ export async function flushTelemetry(): Promise { const timeoutMs = parseInt( process.env.CLAUDE_CODE_OTEL_FLUSH_TIMEOUT_MS || '5000', + 10, ) try { diff --git a/src/utils/thinking.ts b/src/utils/thinking.ts index df62072a5..bc9fff4f7 100644 --- a/src/utils/thinking.ts +++ b/src/utils/thinking.ts @@ -118,10 +118,14 @@ export function modelSupportsAdaptiveThinking(model: string): boolean { } const canonical = getCanonicalName(model) // Supported by a subset of Claude 4 models - if (canonical.includes('opus-4-6') || canonical.includes('sonnet-4-6')) { + if ( + canonical.includes('opus-4-7') || + canonical.includes('opus-4-6') || + canonical.includes('sonnet-4-6') + ) { return true } - // Exclude any other known legacy models (allowlist above catches 4-6 variants first) + // Exclude any other known legacy models (allowlist above catches 4-6+ variants first) if ( canonical.includes('opus') || canonical.includes('sonnet') || diff --git a/src/utils/undercover.ts b/src/utils/undercover.ts index 6b04f8677..177c819b9 100644 --- a/src/utils/undercover.ts +++ b/src/utils/undercover.ts @@ -46,7 +46,7 @@ information. Do not blow your cover. NEVER include in commit messages or PR descriptions: - Internal model codenames (animal names like Capybara, Tengu, etc.) -- Unreleased model version numbers (e.g., opus-4-7, sonnet-4-8) +- Unreleased model version numbers (e.g., sonnet-4-8) - Internal repo or project names (e.g., claude-cli-internal, anthropics/…) - Internal tooling, Slack channels, or short links (e.g., go/cc, #claude-code-…) - The phrase "Claude Code" or any mention that you are an AI @@ -64,8 +64,10 @@ GOOD: BAD (never write these): - "Fix bug found while testing with Claude Capybara" - "1-shotted by claude-opus-4-6" +- "1-shotted by claude-opus-4-7" - "Generated with Claude Code" - "Co-Authored-By: Claude Opus 4.6 <…>" +- "Co-Authored-By: Claude Opus 4.7 <…>" ` } return '' diff --git a/src/utils/windowsPaths.ts b/src/utils/windowsPaths.ts index c6b544bc7..d610f69c5 100644 --- a/src/utils/windowsPaths.ts +++ b/src/utils/windowsPaths.ts @@ -99,7 +99,6 @@ export const findGitBashPath = memoize((): string => { if (checkPathExists(process.env.CLAUDE_CODE_GIT_BASH_PATH)) { return process.env.CLAUDE_CODE_GIT_BASH_PATH } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`, ) @@ -115,7 +114,6 @@ export const findGitBashPath = memoize((): string => { } } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( 'Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win). If installed but not in PATH, set environment variable pointing to your bash.exe, similar to: CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe', ) diff --git a/src/utils/workflowRuns.ts b/src/utils/workflowRuns.ts new file mode 100644 index 000000000..1422891d1 --- /dev/null +++ b/src/utils/workflowRuns.ts @@ -0,0 +1,160 @@ +import { readdir, readFile } from 'fs/promises' +import { join } from 'path' +import { getProjectRoot } from '../bootstrap/state.js' +import { safeParseJSON } from './json.js' + +const WORKFLOW_RUNS_REL = join('.claude', 'workflow-runs') +const MAX_WORKFLOW_RUNS = 200 + +const WORKFLOW_RUN_STATUSES = ['running', 'completed', 'cancelled'] as const +const WORKFLOW_STEP_STATUSES = [ + 'pending', + 'running', + 'completed', + 'cancelled', +] as const + +type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] +type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] + +export type WorkflowRunStepRecord = { + name: string + prompt?: string + status: WorkflowStepStatus + startedAt?: number + completedAt?: number +} + +export type WorkflowRunRecord = { + runId: string + workflow: string + args?: string + status: WorkflowRunStatus + createdAt: number + updatedAt: number + currentStepIndex: number + steps: WorkflowRunStepRecord[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isWorkflowRunStatus(value: unknown): value is WorkflowRunStatus { + return ( + typeof value === 'string' && + WORKFLOW_RUN_STATUSES.includes(value as WorkflowRunStatus) + ) +} + +function isWorkflowStepStatus(value: unknown): value is WorkflowStepStatus { + return ( + typeof value === 'string' && + WORKFLOW_STEP_STATUSES.includes(value as WorkflowStepStatus) + ) +} + +function normalizeWorkflowStep(value: unknown): WorkflowRunStepRecord | null { + if (!isRecord(value)) return null + if (typeof value.name !== 'string') return null + if (!isWorkflowStepStatus(value.status)) return null + return { + name: value.name, + ...(typeof value.prompt === 'string' ? { prompt: value.prompt } : {}), + status: value.status, + ...(typeof value.startedAt === 'number' + ? { startedAt: value.startedAt } + : {}), + ...(typeof value.completedAt === 'number' + ? { completedAt: value.completedAt } + : {}), + } +} + +function normalizeWorkflowRun(value: unknown): WorkflowRunRecord | null { + if (!isRecord(value)) return null + if (typeof value.runId !== 'string') return null + if (typeof value.workflow !== 'string') return null + if (!isWorkflowRunStatus(value.status)) return null + if (typeof value.createdAt !== 'number') return null + if (typeof value.updatedAt !== 'number') return null + if (typeof value.currentStepIndex !== 'number') return null + if (!Array.isArray(value.steps)) return null + const steps = value.steps + .map(normalizeWorkflowStep) + .filter((step): step is WorkflowRunStepRecord => step !== null) + if (steps.length !== value.steps.length) return null + return { + runId: value.runId, + workflow: value.workflow, + ...(typeof value.args === 'string' ? { args: value.args } : {}), + status: value.status, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + currentStepIndex: value.currentStepIndex, + steps, + } +} + +async function readWorkflowRun( + rootDir: string, + runId: string, +): Promise { + try { + const parsed = safeParseJSON( + await readFile( + join(rootDir, WORKFLOW_RUNS_REL, `${runId}.json`), + 'utf-8', + ), + false, + ) + return normalizeWorkflowRun(parsed) + } catch { + return null + } +} + +export async function listWorkflowRuns( + rootDir: string = getProjectRoot(), +): Promise { + let files: string[] + try { + files = await readdir(join(rootDir, WORKFLOW_RUNS_REL)) + } catch { + return [] + } + const jsonFiles = files.filter(file => file.endsWith('.json')) + const runs = await Promise.all( + jsonFiles + .slice(0, MAX_WORKFLOW_RUNS) + .map(file => readWorkflowRun(rootDir, file.slice(0, -'.json'.length))), + ) + return runs + .filter((run): run is WorkflowRunRecord => run !== null) + .sort((a, b) => b.updatedAt - a.updatedAt) +} + +export function formatWorkflowRunsStatus(runs: WorkflowRunRecord[]): string { + if (runs.length === 0) { + return ['Workflow runs: 0', ' none'].join('\n') + } + const running = runs.filter(run => run.status === 'running').length + const completed = runs.filter(run => run.status === 'completed').length + const cancelled = runs.filter(run => run.status === 'cancelled').length + const lines = [ + `Workflow runs: ${runs.length}`, + ` Running: ${running}`, + ` Completed: ${completed}`, + ` Cancelled: ${cancelled}`, + ] + for (const run of runs.slice(0, 10)) { + const currentStep = run.steps[run.currentStepIndex] + lines.push( + ` ${run.runId}: ${run.workflow}: ${run.status} step=${currentStep?.name ?? 'none'} updated=${new Date(run.updatedAt).toLocaleString()}`, + ) + } + if (runs.length > 10) { + lines.push(` ... ${runs.length - 10} more workflow run(s)`) + } + return lines.join('\n') +} diff --git a/src/utils/worktree.ts b/src/utils/worktree.ts index dc4b3db3a..8cb20f8e3 100644 --- a/src/utils/worktree.ts +++ b/src/utils/worktree.ts @@ -1268,7 +1268,6 @@ export async function execIntoTmuxWorktree(args: string[]): Promise<{ } } repoName = basename(findCanonicalGitRoot(getCwd()) ?? getCwd()) - // biome-ignore lint/suspicious/noConsole: intentional console output console.log(`Using worktree via hook: ${worktreeDir}`) } else { // Get main git repo root (resolves through worktrees) @@ -1291,7 +1290,6 @@ export async function execIntoTmuxWorktree(args: string[]): Promise<{ prNumber !== null ? { prNumber } : undefined, ) if (!result.existed) { - // biome-ignore lint/suspicious/noConsole: intentional console output console.log( `Created worktree: ${worktreeDir} (based on ${(result as any).baseBranch})`, ) @@ -1383,7 +1381,6 @@ export async function execIntoTmuxWorktree(args: string[]): Promise<{ // Print hint about iTerm2 preferences when using control mode if (useControlMode && !sessionExists) { const y = chalk.yellow - // biome-ignore lint/suspicious/noConsole: intentional user guidance console.log( `\n${y('╭─ iTerm2 Tip ────────────────────────────────────────────────────────╮')}\n` + `${y('│')} To open as a tab instead of a new window: ${y('│')}\n` +