mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 添加工具类增强与状态管理改进
- 新增 workflowRuns、remoteTriggerAudit、pipeStatus 等工具 - 增强 permissionSetup: auto mode 和 bypass permissions 始终可用 - 新增多组测试覆盖 (modifiers, teamDiscovery, deepLink 等) - 修复 parseInt 缺少 radix 参数 - 移除多余 biome-ignore 注释 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -381,7 +381,7 @@ export function useMultiSelectState<T>({
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -255,7 +255,7 @@ export const useSelectInput = <T>({
|
||||
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) {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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()
|
||||
|
||||
96
src/utils/__tests__/modifiers.test.ts
Normal file
96
src/utils/__tests__/modifiers.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
69
src/utils/__tests__/pipeStatus.test.ts
Normal file
69
src/utils/__tests__/pipeStatus.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
37
src/utils/__tests__/remoteControlStatus.test.ts
Normal file
37
src/utils/__tests__/remoteControlStatus.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
43
src/utils/__tests__/remoteTriggerAudit.test.ts
Normal file
43
src/utils/__tests__/remoteTriggerAudit.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
68
src/utils/__tests__/teamDiscovery.test.ts
Normal file
68
src/utils/__tests__/teamDiscovery.test.ts
Normal file
@@ -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',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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} <noreply@anthropic.com>`
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
: 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<boolean> {
|
||||
: 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<string, string> {
|
||||
const debounceMs = parseInt(
|
||||
process.env.CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS ||
|
||||
DEFAULT_OTEL_HEADERS_DEBOUNCE_MS.toString(),
|
||||
10,
|
||||
)
|
||||
if (
|
||||
cachedOtelHeaders &&
|
||||
|
||||
@@ -81,7 +81,6 @@ export async function assertMinVersion(): Promise<void> {
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
93
src/utils/deepLink/__tests__/protocolHandler.test.ts
Normal file
93
src/utils/deepLink/__tests__/protocolHandler.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -94,11 +94,13 @@ export async function handleUrlSchemeLaunch(): Promise<number | null> {
|
||||
|
||||
try {
|
||||
const { waitForUrlEvent } = await import('url-handler-napi')
|
||||
const url = (waitForUrlEvent as any)(5000)
|
||||
const url = await (
|
||||
waitForUrlEvent as (timeoutMs?: number) => Promise<string | null>
|
||||
)(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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -1109,7 +1109,6 @@ async function readFileAsyncOrNull(path: string): Promise<string | null> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,7 +22,9 @@ export async function returnValue<A>(
|
||||
}
|
||||
|
||||
type QueuedGenerator<A> = {
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void matches AsyncGenerator<A, void> return type
|
||||
done: boolean | void
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void matches AsyncGenerator<A, void> yield type
|
||||
value: A | void
|
||||
generator: AsyncGenerator<A, void>
|
||||
promise: Promise<QueuedGenerator<A>>
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ async function readIdeLockfile(path: string): Promise<IdeLockfileInfo | null> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}</${COMMAND_NAME_TAG}>\n` +
|
||||
`<loaded-skill name="${s.name}" path="${s.path ?? ''}">\n${s.content}\n</loaded-skill>`,
|
||||
)
|
||||
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("<name>") for complete instructions.`,
|
||||
content: [
|
||||
loadedSections.length > 0
|
||||
? `The following skills are auto-loaded for this task. Apply their instructions now; do not call Skill("<name>") 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("<name>") 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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AutoModeGateCheckResult> {
|
||||
// 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<boolean> {
|
||||
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<void> {
|
||||
// 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
|
||||
}
|
||||
|
||||
32
src/utils/pipeStatus.ts
Normal file
32
src/utils/pipeStatus.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { PipeRegistry } from './pipeRegistry.js'
|
||||
import { readRegistry } from './pipeRegistry.js'
|
||||
|
||||
export async function formatPipeRegistryStatus(): Promise<string> {
|
||||
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')
|
||||
}
|
||||
@@ -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.',
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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}'
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
23
src/utils/remoteControlStatus.ts
Normal file
23
src/utils/remoteControlStatus.ts
Normal file
@@ -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')
|
||||
}
|
||||
}
|
||||
91
src/utils/remoteTriggerAudit.ts
Normal file
91
src/utils/remoteTriggerAudit.ts
Normal file
@@ -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<RemoteTriggerAuditRecord, 'auditId' | 'createdAt'> & {
|
||||
auditId?: string
|
||||
createdAt?: number
|
||||
},
|
||||
rootDir: string = getProjectRoot(),
|
||||
): Promise<RemoteTriggerAuditRecord> {
|
||||
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<RemoteTriggerAuditRecord[]> {
|
||||
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<RemoteTriggerAuditRecord>
|
||||
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')
|
||||
}
|
||||
@@ -52,7 +52,6 @@ function spawnSecurity(serviceName: string): Promise<SpawnResult> {
|
||||
// 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),
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
const timeoutMs = parseInt(
|
||||
process.env.CLAUDE_CODE_OTEL_FLUSH_TIMEOUT_MS || '5000',
|
||||
10,
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
160
src/utils/workflowRuns.ts
Normal file
160
src/utils/workflowRuns.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<WorkflowRunRecord | null> {
|
||||
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<WorkflowRunRecord[]> {
|
||||
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')
|
||||
}
|
||||
@@ -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` +
|
||||
|
||||
Reference in New Issue
Block a user