feat(settings): restore authoritative Agent Teams control in General

This commit is contained in:
程序员阿江(Relakkes)
2026-09-15 15:34:47 +08:00
parent 79f6e58b40
commit 7eb466228c
24 changed files with 611 additions and 14 deletions
@@ -249,6 +249,7 @@ describe('Settings > General tab', () => {
autoModeOptInAccepted: false,
thinkingEnabled: true,
workflowKeywordTriggerEnabled: true,
agentTeamsEnabled: true,
autoDreamEnabled: false,
skipWebFetchPreflight: true,
desktopNotificationsEnabled: true,
@@ -1270,6 +1271,52 @@ describe('Settings > General tab', () => {
}
})
it('saves Agent Teams changes and explains when they take effect', async () => {
const updateUser = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true })
try {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const toggle = screen.getByRole('switch', { name: 'Enable Agent Teams' })
expect(toggle).toBeChecked()
expect(screen.getByText('Applies to new sessions. Restart the app to apply changes to existing sessions.')).toBeVisible()
await act(async () => { fireEvent.click(toggle) })
expect(toggle).not.toBeChecked()
expect(updateUser).toHaveBeenLastCalledWith({ agentTeamsEnabled: false })
await act(async () => { fireEvent.click(toggle) })
expect(toggle).toBeChecked()
expect(updateUser).toHaveBeenLastCalledWith({ agentTeamsEnabled: true })
} finally {
updateUser.mockRestore()
}
})
it('disables Agent Teams while saving and reports failure after rolling back', async () => {
let rejectSave!: (error: Error) => void
const updateUser = vi.spyOn(settingsApi, 'updateUser').mockImplementation(() => new Promise((_, reject) => {
rejectSave = reject
}))
try {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const toggle = screen.getByRole('switch', { name: 'Enable Agent Teams' })
fireEvent.click(toggle)
expect(toggle).toBeDisabled()
expect(toggle).not.toBeChecked()
expect(updateUser).toHaveBeenCalledTimes(1)
await act(async () => { rejectSave(new Error('disk full')) })
expect(toggle).toBeEnabled()
expect(toggle).toBeChecked()
expect(useUIStore.getState().toasts.at(-1)).toMatchObject({
type: 'error',
message: 'Failed to save Agent Teams settings. Please try again.',
})
} finally {
updateUser.mockRestore()
}
})
it('lets the user choose a default permission mode for new sessions', async () => {
render(<Settings />)
+5
View File
@@ -2188,6 +2188,11 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.general.defaultEditorTitle': 'Default editor',
'settings.general.defaultEditorDescription': 'Which editor the Open With menu offers for a file. Only editors found on this machine are listed.',
'settings.general.defaultEditorAuto': 'Automatic',
'settings.general.agentTeamsTitle': 'Agent Teams',
'settings.general.agentTeamsDescription': 'Let multiple agents divide tasks and collaborate as a team.',
'settings.general.agentTeamsEnabled': 'Enable Agent Teams',
'settings.general.agentTeamsHint': 'Applies to new sessions. Restart the app to apply changes to existing sessions.',
'settings.general.agentTeamsSaveFailed': 'Failed to save Agent Teams settings. Please try again.',
'settings.general.workflowKeywordTitle': 'Ultracode',
'settings.general.workflowKeywordDescription': 'Controls whether a standalone Ultracode keyword opts the prompt into dynamic Workflow orchestration.',
'settings.general.workflowKeywordEnabled': 'Enable Ultracode keyword trigger',
+5
View File
@@ -2191,6 +2191,11 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.defaultEditorTitle': '既定のエディタ',
'settings.general.defaultEditorDescription': 'ファイルの「開く方法」メニューに表示するエディタ。このマシンで検出されたエディタのみ表示されます。',
'settings.general.defaultEditorAuto': '自動',
'settings.general.agentTeamsTitle': 'Agent Teams',
'settings.general.agentTeamsDescription': '複数のエージェントがタスクを分担し、チームで協力できるようにします。',
'settings.general.agentTeamsEnabled': 'Agent Teams を有効にする',
'settings.general.agentTeamsHint': '新しいセッションに適用されます。既存のセッションに適用するにはアプリを再起動してください。',
'settings.general.agentTeamsSaveFailed': 'Agent Teams の設定を保存できませんでした。もう一度お試しください。',
'settings.general.workflowKeywordTitle': 'Ultracode',
'settings.general.workflowKeywordDescription': '単独の Ultracode キーワードでプロンプトを動的な Workflow オーケストレーションに切り替えるかを制御します。',
'settings.general.workflowKeywordEnabled': 'Ultracode キーワードトリガーを有効にする',
+5
View File
@@ -2191,6 +2191,11 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.defaultEditorTitle': '기본 편집기',
'settings.general.defaultEditorDescription': '파일의 연결 프로그램 메뉴에 표시할 편집기입니다. 이 컴퓨터에서 감지된 편집기만 나열됩니다.',
'settings.general.defaultEditorAuto': '자동',
'settings.general.agentTeamsTitle': 'Agent Teams',
'settings.general.agentTeamsDescription': '여러 에이전트가 작업을 나누고 팀으로 협력할 수 있습니다.',
'settings.general.agentTeamsEnabled': 'Agent Teams 사용',
'settings.general.agentTeamsHint': '새 세션에 적용됩니다. 기존 세션에 적용하려면 앱을 다시 시작하세요.',
'settings.general.agentTeamsSaveFailed': 'Agent Teams 설정을 저장하지 못했습니다. 다시 시도하세요.',
'settings.general.workflowKeywordTitle': 'Ultracode',
'settings.general.workflowKeywordDescription': '독립된 Ultracode 키워드가 프롬프트를 동적 Workflow 오케스트레이션으로 전환할지 제어합니다.',
'settings.general.workflowKeywordEnabled': 'Ultracode 키워드 트리거 사용',
+5
View File
@@ -2190,6 +2190,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.defaultEditorTitle': '預設編輯器',
'settings.general.defaultEditorDescription': '檔案的「開啟方式」選單裡提供哪個編輯器。僅列出本機已偵測到的編輯器。',
'settings.general.defaultEditorAuto': '自動選擇',
'settings.general.agentTeamsTitle': 'Agent Teams 智慧體團隊',
'settings.general.agentTeamsDescription': '讓多個 Agent 分工協作,共同完成任務。',
'settings.general.agentTeamsEnabled': '啟用 Agent Teams',
'settings.general.agentTeamsHint': '對新建工作階段生效,既有工作階段需重新啟動應用程式後生效。',
'settings.general.agentTeamsSaveFailed': 'Agent Teams 設定儲存失敗,請重試。',
'settings.general.workflowKeywordTitle': 'Ultracode',
'settings.general.workflowKeywordDescription': '控制獨立出現的 Ultracode 關鍵詞是否讓目前提示詞進入動態 Workflow 編排。',
'settings.general.workflowKeywordEnabled': '啟用 Ultracode 關鍵詞觸發',
+5
View File
@@ -2189,6 +2189,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.defaultEditorTitle': '默认编辑器',
'settings.general.defaultEditorDescription': '文件的「打开方式」菜单里提供哪个编辑器。只列出本机已检测到的编辑器。',
'settings.general.defaultEditorAuto': '自动选择',
'settings.general.agentTeamsTitle': 'Agent Teams 智能体团队',
'settings.general.agentTeamsDescription': '让多个 Agent 分工协作,共同完成任务。',
'settings.general.agentTeamsEnabled': '启用 Agent Teams',
'settings.general.agentTeamsHint': '对新建会话生效,已有会话重启应用后生效。',
'settings.general.agentTeamsSaveFailed': 'Agent Teams 设置保存失败,请重试。',
'settings.general.workflowKeywordTitle': 'Ultracode',
'settings.general.workflowKeywordDescription': '控制独立出现的 Ultracode 关键词是否让当前提示词进入动态 Workflow 编排。',
'settings.general.workflowKeywordEnabled': '启用 Ultracode 关键词触发',
@@ -70,6 +70,8 @@ export function GeneralSettings() {
setThinkingEnabled,
workflowKeywordTriggerEnabled,
setWorkflowKeywordTriggerEnabled,
agentTeamsEnabled,
setAgentTeamsEnabled,
permissionMode,
setPermissionMode,
autoDreamEnabled,
@@ -129,6 +131,7 @@ export function GeneralSettings() {
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
const [autoDreamConfirmOpen, setAutoDreamConfirmOpen] = useState(false)
const [autoDreamActionRunning, setAutoDreamActionRunning] = useState(false)
const [agentTeamsSaving, setAgentTeamsSaving] = useState(false)
const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false)
const [pendingMode, setPendingMode] = useState<AppMode | null>(null)
const [pendingPortableDir, setPendingPortableDir] = useState<string | null>(null)
@@ -587,6 +590,18 @@ export function GeneralSettings() {
}
}
const handleAgentTeamsChange = async (enabled: boolean) => {
if (agentTeamsSaving) return
setAgentTeamsSaving(true)
try {
await setAgentTeamsEnabled(enabled)
} catch {
addToast({ type: 'error', message: t('settings.general.agentTeamsSaveFailed') })
} finally {
setAgentTeamsSaving(false)
}
}
const handleOutputStyleChange = async (value: string) => {
try {
await setOutputStyle(value, outputStyleWorkDir)
@@ -1109,6 +1124,22 @@ export function GeneralSettings() {
</div>
</SettingsSection>
<SettingsSection
className="mt-8"
title={t('settings.general.agentTeamsTitle')}
description={t('settings.general.agentTeamsDescription')}
>
<div className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<Switch
checked={agentTeamsEnabled}
onChange={(enabled) => void handleAgentTeamsChange(enabled)}
disabled={agentTeamsSaving}
label={t('settings.general.agentTeamsEnabled')}
description={t('settings.general.agentTeamsHint')}
/>
</div>
</SettingsSection>
<div>
<h2 className="text-[16.5px] font-semibold leading-tight text-[var(--color-text-primary)] mb-1" style={{ fontFamily: 'var(--font-headline)' }}>{t('settings.general.autoDreamTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.autoDreamDescription')}</p>
+69
View File
@@ -1081,6 +1081,75 @@ describe('settingsStore workflow keyword persistence', () => {
})
})
describe('settingsStore Agent Teams persistence', () => {
const updateUser = vi.fn()
const getUser = vi.fn()
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
window.localStorage.clear()
updateUser.mockReset().mockResolvedValue({ ok: true })
getUser.mockReset().mockResolvedValue({})
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser,
updateUser,
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
},
}))
vi.doMock('../api/models', () => ({
modelsApi: {
list: vi.fn().mockResolvedValue({ models: [] }),
getCurrent: vi.fn().mockResolvedValue({ model: null }),
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
},
}))
vi.doMock('../api/h5Access', () => ({
h5AccessApi: { get: vi.fn().mockResolvedValue({ settings: {} }) },
}))
vi.doMock('../api/traces', () => ({
tracesApi: { getSettings: vi.fn().mockResolvedValue({ enabled: true, storageDir: '/tmp/test-traces' }) },
}))
})
it.each([
[{}, true],
[{ agentTeamsEnabled: true }, true],
[{ agentTeamsEnabled: false }, false],
])('loads Agent Teams from user settings %j', async (settings, expected) => {
getUser.mockResolvedValue(settings)
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().agentTeamsEnabled).toBe(true)
await useSettingsStore.getState().fetchAll()
expect(useSettingsStore.getState().agentTeamsEnabled).toBe(expected)
})
it('persists disabling and re-enabling Agent Teams', async () => {
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().setAgentTeamsEnabled(false)
expect(useSettingsStore.getState().agentTeamsEnabled).toBe(false)
await useSettingsStore.getState().setAgentTeamsEnabled(true)
expect(updateUser).toHaveBeenNthCalledWith(1, { agentTeamsEnabled: false })
expect(updateUser).toHaveBeenNthCalledWith(2, { agentTeamsEnabled: true })
expect(useSettingsStore.getState().agentTeamsEnabled).toBe(true)
})
it.each([true, false])('rolls back an optimistic update from %s and rejects on save failure', async (previous) => {
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({ agentTeamsEnabled: previous })
const deferred = createDeferred<{ ok: boolean }>()
updateUser.mockReturnValueOnce(deferred.promise)
const saving = useSettingsStore.getState().setAgentTeamsEnabled(!previous)
expect(useSettingsStore.getState().agentTeamsEnabled).toBe(!previous)
const failure = new Error('disk full')
const rejected = expect(saving).rejects.toBe(failure)
deferred.reject(failure)
await rejected
expect(useSettingsStore.getState().agentTeamsEnabled).toBe(previous)
})
})
describe('settingsStore Auto-dream persistence', () => {
beforeEach(() => {
vi.resetModules()
+15
View File
@@ -61,6 +61,7 @@ type SettingsStore = {
effortLevel: EffortLevel
thinkingEnabled: boolean
workflowKeywordTriggerEnabled: boolean
agentTeamsEnabled: boolean
autoDreamEnabled: boolean
autoModeOptInAccepted: boolean
availableModels: ModelInfo[]
@@ -105,6 +106,7 @@ type SettingsStore = {
setEffort: (level: EffortLevel) => Promise<void>
setThinkingEnabled: (enabled: boolean) => Promise<void>
setWorkflowKeywordTriggerEnabled: (enabled: boolean) => Promise<void>
setAgentTeamsEnabled: (enabled: boolean) => Promise<void>
setAutoDreamEnabled: (enabled: boolean) => Promise<void>
acceptAutoModeOptIn: () => Promise<void>
setLocale: (locale: Locale) => void
@@ -197,6 +199,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
effortLevel: 'max',
thinkingEnabled: true,
workflowKeywordTriggerEnabled: true,
agentTeamsEnabled: true,
autoDreamEnabled: false,
autoModeOptInAccepted: false,
availableModels: [],
@@ -274,6 +277,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
effortLevel: level,
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
workflowKeywordTriggerEnabled: userSettings.workflowKeywordTriggerEnabled !== false,
agentTeamsEnabled: userSettings.agentTeamsEnabled !== false,
autoDreamEnabled: userSettings.autoDreamEnabled === true,
autoModeOptInAccepted: userSettings.skipAutoPermissionPrompt === true,
chatSendBehavior: normalizeChatSendBehavior(userSettings.chatSendBehavior),
@@ -358,6 +362,17 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
}
},
setAgentTeamsEnabled: async (enabled) => {
const prev = get().agentTeamsEnabled
set({ agentTeamsEnabled: enabled })
try {
await settingsApi.updateUser({ agentTeamsEnabled: enabled })
} catch (error) {
set({ agentTeamsEnabled: prev })
throw error
}
},
setAutoDreamEnabled: async (enabled) => {
const prev = get().autoDreamEnabled
set({ autoDreamEnabled: enabled })
+1
View File
@@ -153,6 +153,7 @@ export type UserSettings = {
effort?: EffortLevel
alwaysThinkingEnabled?: boolean
workflowKeywordTriggerEnabled?: boolean
agentTeamsEnabled?: boolean
autoDreamEnabled?: boolean
skipAutoPermissionPrompt?: boolean
permissionMode?: PermissionMode
@@ -10,6 +10,7 @@ import {
import { ProviderService } from '../services/providerService.js'
import { updateTraceCaptureSettings } from '../services/traceCaptureService.js'
import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js'
import { createSandboxedTestEnvironment } from '../../../scripts/pr/test-environment.js'
describe('ConversationService', () => {
let tmpDir: string
@@ -396,6 +397,81 @@ describe('ConversationService', () => {
expect(env.ANTHROPIC_MODEL).toBeUndefined()
})
for (const entrypoint of ['sdk-cli', 'claude-desktop']) {
for (const { settingsFile, setting, preference } of [
{ settingsFile: 'settings.json', setting: undefined, preference: undefined },
{ settingsFile: 'settings.json', setting: '0', preference: undefined },
{ settingsFile: 'cc-haha/settings.json', setting: 'false', preference: undefined },
{ settingsFile: 'cc-haha/settings.json', setting: 'false', preference: true },
{ settingsFile: 'settings.json', setting: '1', preference: false },
]) {
test(`desktop team tools survive child startup (${entrypoint}, ${settingsFile}=${setting ?? 'unset'}, preference=${preference ?? 'unset'})`, async () => {
if (setting !== undefined) {
const legacyPath = path.join(tmpDir, settingsFile)
await fs.mkdir(path.dirname(legacyPath), { recursive: true })
await fs.writeFile(legacyPath, JSON.stringify({
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: setting },
}))
}
if (preference !== undefined) {
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({
agentTeamsEnabled: preference,
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: setting },
}))
}
const service = new ConversationService() as any
service.shouldMarkManagedOAuth = () => entrypoint === 'claude-desktop'
service.buildOfficialOAuthEnv = async () => ({ CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' })
const childEnv = await service.buildChildEnv(tmpDir, 'ws://127.0.0.1:3456/sdk/test')
const probeHome = path.join(tmpDir, 'team-probe')
const probeEnv = createSandboxedTestEnvironment(probeHome, {
CLAUDE_CODE_ENTRYPOINT: childEnv.CLAUDE_CODE_ENTRYPOINT ?? 'sdk-cli',
ANTHROPIC_API_KEY: 'fake-team-probe-key',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
DISABLE_TELEMETRY: '1',
})
// Pass the host's real team defaults across a fresh process boundary,
// without inheriting provider credentials or any user configuration.
for (const key of ['CC_HAHA_AGENT_TEAMS_DEFAULT', 'CC_HAHA_AGENT_TEAMS_ENABLED', 'CLAUDE_CODE_ENABLE_TASKS']) {
if (childEnv[key] !== undefined) probeEnv[key] = childEnv[key]
}
if (setting !== undefined) {
const settingsPath = path.join(probeEnv.CLAUDE_CONFIG_DIR!, settingsFile)
await fs.mkdir(path.dirname(settingsPath), { recursive: true })
await fs.writeFile(settingsPath, JSON.stringify({
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: setting },
}))
}
const repoRoot = path.resolve(import.meta.dir, '../../..')
const probe = Bun.spawn([process.execPath, '--no-env-file', '--preload', path.join(repoRoot, 'preload.ts'), '-e', `
const { applySafeConfigEnvironmentVariables, applyConfigEnvironmentVariables } = await import(${JSON.stringify(path.join(repoRoot, 'src/utils/managedEnv.ts'))})
applySafeConfigEnvironmentVariables()
applyConfigEnvironmentVariables()
const { getAllBaseTools } = await import(${JSON.stringify(path.join(repoRoot, 'src/tools.ts'))})
const { default: teamCommand } = await import(${JSON.stringify(path.join(repoRoot, 'src/commands/team.ts'))})
const names = getAllBaseTools().filter(tool => tool.isEnabled()).map(tool => tool.name)
console.log('TEAM_PROBE:' + JSON.stringify({
tools: names.filter(name => ['TeamCreate', 'TeamDelete', 'SendMessage', 'TaskCreate'].includes(name)),
command: teamCommand.isEnabled(),
}))
`], { cwd: probeHome, env: probeEnv, stdout: 'pipe', stderr: 'pipe' })
const [stdout, stderr, code] = await Promise.all([
new Response(probe.stdout).text(),
new Response(probe.stderr).text(),
probe.exited,
])
expect({ code, stderr }).toEqual({ code: 0, stderr: '' })
const report = JSON.parse(stdout.split('\n').find(line => line.startsWith('TEAM_PROBE:'))!.slice('TEAM_PROBE:'.length))
expect(report.tools).toContain('TaskCreate')
const expected = preference ?? (setting === undefined)
expect(report.command).toBe(expected)
for (const name of ['TeamCreate', 'TeamDelete', 'SendMessage']) {
expect(report.tools.includes(name)).toBe(expected)
}
})
}
}
test('buildChildEnv injects General network timeout and manual proxy for CLI requests', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
@@ -515,6 +515,65 @@ describe('cron scheduler launcher resolution', () => {
}
})
unixOnly('executeTask reloads the General team preference for each new scheduled process', async () => {
const appRoot = path.join(tmpDir, 'app-root')
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
const teamEnvPath = path.join(tmpDir, 'team.env')
const settingsPath = path.join(process.env.CLAUDE_CONFIG_DIR!, 'settings.json')
const envKeys = [
'CC_HAHA_AGENT_TEAMS_DEFAULT',
'CC_HAHA_AGENT_TEAMS_ENABLED',
'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
] as const
const savedEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
await fs.mkdir(appRoot, { recursive: true })
await fs.mkdir(path.dirname(settingsPath), { recursive: true })
await fs.writeFile(sidecarPath, [
'#!/bin/sh',
`printf '%s\\n' "$CC_HAHA_AGENT_TEAMS_DEFAULT" "\${CC_HAHA_AGENT_TEAMS_ENABLED-unset}" "$CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" > "${teamEnvPath}"`,
'/bin/cat >/dev/null',
'printf \'%s\\n\' \'{"type":"result","result":"team env ok"}\'',
'',
].join('\n'))
await fs.chmod(sidecarPath, 0o755)
process.env.CLAUDE_CLI_PATH = sidecarPath
process.env.CLAUDE_APP_ROOT = appRoot
process.env.HOME = tmpDir
process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '0'
process.env.CC_HAHA_AGENT_TEAMS_ENABLED = '1'
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '0'
try {
const cronService = new CronService()
const scheduler = new CronScheduler(cronService)
const task = await cronService.createTask({
cron: '* * * * *',
prompt: 'cron team environment test',
recurring: true,
folderPath: tmpDir,
})
for (const enabled of [undefined, true, false, undefined]) {
await fs.writeFile(settingsPath, JSON.stringify(
enabled === undefined ? {} : { agentTeamsEnabled: enabled },
))
const run = await scheduler.executeTask(task)
expect(run.status).toBe('completed')
expect(run.output).toBe('team env ok')
expect((await fs.readFile(teamEnvPath, 'utf-8')).trim().split('\n')).toEqual([
'0',
enabled === true ? '1' : '0',
'0',
])
}
} finally {
for (const key of envKeys) {
const value = savedEnv[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
})
unixOnly('executeTask launches scheduled tasks with full permissions', async () => {
const appRoot = path.join(tmpDir, 'app-root')
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
+2 -2
View File
@@ -185,9 +185,9 @@ describe('E2E: Full Flow', () => {
// 3. Settings
// =============================================
it('should get empty settings initially', async () => {
it('should expose the default team preference in initial settings', async () => {
const { data } = await api('GET', '/api/settings/user')
expect(data).toEqual({})
expect(data).toEqual({ agentTeamsEnabled: true })
})
it('should update and read user settings', async () => {
@@ -3,6 +3,7 @@ import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { ProviderService } from '../services/providerService.js'
import { SettingsService } from '../services/settingsService.js'
import {
CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
ensurePersistentStorageUpgraded,
@@ -32,6 +33,29 @@ describe('persistent storage upgrade migrations', () => {
await fs.rm(tempDir, { recursive: true, force: true })
})
test('upgrades legacy team preferences on read and preserves the original settings on save', async () => {
const userPath = path.join(tempDir, 'settings.json')
const managedDir = path.join(tempDir, 'cc-haha')
await fs.mkdir(managedDir, { recursive: true })
const legacy = {
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', LEGACY_OTHER_ENV: 'preserved' },
unknownFuturePreference: { keep: true },
}
const original = JSON.stringify(legacy)
await fs.writeFile(userPath, original)
await fs.writeFile(path.join(managedDir, 'settings.json'), JSON.stringify({
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '0' },
}))
const service = new SettingsService()
expect(await service.getAgentTeamsEnabled()).toBe(false)
expect(await fs.readFile(userPath, 'utf-8')).toBe(original)
await service.updateUserSettings({ agentTeamsEnabled: true })
expect(await new SettingsService().getAgentTeamsEnabled()).toBe(true)
expect(JSON.parse(await fs.readFile(userPath, 'utf-8'))).toEqual({ ...legacy, agentTeamsEnabled: true })
await service.updateUserSettings({ agentTeamsEnabled: false })
expect(await new SettingsService().getAgentTeamsEnabled()).toBe(false)
})
test('migrates legacy providers index and writes a backup before changing it', async () => {
const ccHahaDir = path.join(tempDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
+39 -1
View File
@@ -44,6 +44,7 @@ import {
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import { clearAllOutputStylesCache } from '../../constants/outputStyles.js'
import { clearOutputStyleCaches } from '../../outputStyles/loadOutputStylesDir.js'
import * as terminalShellEnvironment from '../../utils/terminalShellEnvironment.js'
// ─── Test helpers ─────────────────────────────────────────────────────────────
@@ -240,6 +241,19 @@ describe('SettingsService', () => {
expect(settings).toEqual({})
})
it('reads the same legacy shell opt-out used when spawning desktop sessions', async () => {
const shellEnv = spyOn(terminalShellEnvironment, 'getProcessEnvWithTerminalShellEnvironment')
.mockResolvedValue({ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '0' })
try {
const service = new SettingsService()
expect(await service.getAgentTeamsEnabled()).toBe(false)
await service.updateUserSettings({ agentTeamsEnabled: true })
expect(await service.getAgentTeamsEnabled()).toBe(true)
} finally {
shellEnv.mockRestore()
}
})
it('should recover from malformed user settings after an upgrade', async () => {
await fs.writeFile(path.join(tmpDir, 'settings.json'), '{not json', 'utf-8')
@@ -488,7 +502,31 @@ describe('Settings API', () => {
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({})
expect(body).toEqual({ agentTeamsEnabled: true })
})
it('persists the General team preference across reads and overrides legacy env', async () => {
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '0', KEEP_ME: 'unchanged' },
futureSetting: { keep: true },
}))
for (const enabled of [true, false]) {
const put = makeRequest('PUT', '/api/settings/user', { agentTeamsEnabled: enabled })
expect((await handleSettingsApi(put.req, put.url, put.segments)).status).toBe(200)
const get = makeRequest('GET', '/api/settings/user')
const response = await handleSettingsApi(get.req, get.url, get.segments)
expect(await response.json()).toMatchObject({ agentTeamsEnabled: enabled, futureSetting: { keep: true } })
const persisted = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8'))
expect(persisted.agentTeamsEnabled).toBe(enabled)
expect(persisted.env.KEEP_ME).toBe('unchanged')
expect(persisted.schemaVersion).toBeUndefined()
}
})
it.each(['false', null, 1])('rejects invalid team preference %j without changing settings', async value => {
const put = makeRequest('PUT', '/api/settings/user', { agentTeamsEnabled: value })
expect((await handleSettingsApi(put.req, put.url, put.segments)).status).toBe(400)
expect(await new SettingsService().getUserSettings()).toEqual({})
})
it('PUT /api/settings/user should update user settings', async () => {
+4 -1
View File
@@ -101,7 +101,10 @@ export async function handleSettingsApi(
async function handleUserSettings(req: Request): Promise<Response> {
if (req.method === 'GET') {
return Response.json(await settingsService.getUserSettings())
return Response.json({
...await settingsService.getUserSettings(),
agentTeamsEnabled: await settingsService.getAgentTeamsEnabled(),
})
}
if (req.method === 'PUT') {
@@ -10,6 +10,7 @@ import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { ProviderService } from './providerService.js'
import { SettingsService } from './settingsService.js'
import {
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
OPENAI_OAUTH_PROVIDER_ENV_KEY,
@@ -1631,6 +1632,7 @@ export class ConversationService {
// earlier); the per-turn hot update below mirrors this for live turns.
const streamMaxDurationMs = resolveStreamMaxDurationMs(networkEnv.API_TIMEOUT_MS)
const traceCaptureEnabled = (await readTraceCaptureSettings()).enabled
const agentTeamsEnabled = await new SettingsService().getAgentTeamsEnabled()
if (explicitProviderEnv && options?.model?.trim()) {
explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()
}
@@ -1652,6 +1654,8 @@ export class ConversationService {
return {
...cleanEnv,
CLAUDE_CODE_ENABLE_TASKS: '1',
// Resolve the same preference shown in General before launching the CLI.
CC_HAHA_AGENT_TEAMS_ENABLED: agentTeamsEnabled ? '1' : '0',
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1',
// Desktop must fail stuck provider streams instead of leaving the UI running forever.
CLAUDE_ENABLE_STREAM_WATCHDOG: cleanEnv.CLAUDE_ENABLE_STREAM_WATCHDOG || '1',
+4
View File
@@ -16,6 +16,7 @@ import { CronService, type CronTask } from './cronService.js'
import { SessionService } from './sessionService.js'
import { sendTaskNotification } from './notificationService.js'
import { ProviderService } from './providerService.js'
import { SettingsService } from './settingsService.js'
import { isProviderManagedEnvVar } from '../../utils/managedEnvConstants.js'
import {
buildClaudeCliArgs,
@@ -852,6 +853,7 @@ export class CronScheduler {
): Promise<Record<string, string | undefined>> {
const cleanEnv = await getProcessEnvWithTerminalShellEnvironment()
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN
delete cleanEnv.CC_HAHA_AGENT_TEAMS_ENABLED
if (this.shouldStripInheritedProviderEnv(task.providerId)) {
for (const key of Object.keys(cleanEnv)) {
@@ -877,10 +879,12 @@ export class CronScheduler {
await loadNetworkSettings(),
cleanEnv,
)
const agentTeamsEnabled = await new SettingsService().getAgentTeamsEnabled()
return {
...cleanEnv,
CLAUDE_CODE_ENABLE_TASKS: '1',
CC_HAHA_AGENT_TEAMS_ENABLED: agentTeamsEnabled ? '1' : '0',
CLAUDE_CODE_ENTRYPOINT: 'sdk-cli',
CALLER_DIR: workDir,
PWD: workDir,
+19
View File
@@ -17,6 +17,8 @@ import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonF
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import { addFileGlobRuleToGitignore } from '../../utils/git/gitignore.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js'
export const VALID_PERMISSION_MODES = [
'default',
@@ -101,6 +103,20 @@ export class SettingsService {
return this.readJsonFile(this.getUserSettingsPath())
}
/** Read-time upgrade for older settings that only stored the team env flag.
* Keep the original file intact until the user explicitly saves a choice.
*/
async getAgentTeamsEnabled(): Promise<boolean> {
const user = await this.getUserSettings()
if (typeof user.agentTeamsEnabled === 'boolean') return user.agentTeamsEnabled
const managed = await this.readJsonFile(path.join(this.getConfigDir(), 'cc-haha', 'settings.json'))
const inherited = await getProcessEnvWithTerminalShellEnvironment()
const key = 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'
const legacyValue = normalizeJsonObject(managed.env)?.[key] ??
normalizeJsonObject(user.env)?.[key] ?? inherited[key]
return typeof legacyValue === 'string' ? isEnvTruthy(legacyValue) : true
}
/** 获取项目级设置 */
async getProjectSettings(projectRoot?: string): Promise<Record<string, unknown>> {
return this.readJsonFile(this.getProjectSettingsPath(projectRoot))
@@ -172,6 +188,9 @@ export class SettingsService {
/** 更新用户级设置(顶层浅合并,并保留桌面终端的未知子字段) */
async updateUserSettings(settings: Record<string, unknown>): Promise<void> {
if (Object.hasOwn(settings, 'agentTeamsEnabled') && typeof settings.agentTeamsEnabled !== 'boolean') {
throw ApiError.badRequest('agentTeamsEnabled must be a boolean')
}
const filePath = this.getUserSettingsPath()
await this.withWriteLock(filePath, async () => {
const current = await this.readJsonFile(filePath)
+125
View File
@@ -0,0 +1,125 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const envKeys = [
'HOME',
'CLAUDE_CONFIG_DIR',
'USER_TYPE',
'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
'CC_HAHA_AGENT_TEAMS_DEFAULT',
'CC_HAHA_AGENT_TEAMS_ENABLED',
] as const
describe('Agent Teams runtime opt-in', () => {
let originalEnv: Record<string, string | undefined>
let originalArgv: string[]
let fixtureDir: string
let isAgentSwarmsEnabled: typeof import('./agentSwarmsEnabled.js').isAgentSwarmsEnabled
let growthbook: typeof import('../services/analytics/growthbook.js')
let gate: ReturnType<typeof spyOn>
beforeAll(async () => {
originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
originalArgv = [...process.argv]
fixtureDir = mkdtempSync(join(tmpdir(), 'agent-teams-gate-'))
process.env.HOME = fixtureDir
process.env.CLAUDE_CONFIG_DIR = fixtureDir
growthbook = await import('../services/analytics/growthbook.js')
const runtime = await import('./agentSwarmsEnabled.js')
isAgentSwarmsEnabled = runtime.isAgentSwarmsEnabled
})
beforeEach(() => {
delete process.env.USER_TYPE
delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
delete process.env.CC_HAHA_AGENT_TEAMS_DEFAULT
delete process.env.CC_HAHA_AGENT_TEAMS_ENABLED
process.argv = originalArgv.filter(arg => arg !== '--agent-teams')
gate = spyOn(growthbook, 'getFeatureValue_CACHED_MAY_BE_STALE').mockReturnValue(true)
})
afterEach(() => {
gate.mockRestore()
})
afterAll(() => {
process.argv = originalArgv
for (const key of envKeys) {
const value = originalEnv[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
rmSync(fixtureDir, { recursive: true, force: true })
})
test('enables a cc-haha managed session without an upstream opt-in', () => {
process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1'
expect(isAgentSwarmsEnabled()).toBe(true)
expect(gate).toHaveBeenCalledWith('tengu_amber_flint', true)
})
test.each(['0', 'false', ''])('preserves the explicit user opt-out %j over the host default', value => {
process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1'
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = value
expect(isAgentSwarmsEnabled()).toBe(false)
expect(gate).not.toHaveBeenCalled()
})
test('keeps standalone CLI sessions opt-in', () => {
expect(isAgentSwarmsEnabled()).toBe(false)
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'
expect(isAgentSwarmsEnabled()).toBe(true)
})
test.each([true, false])('uses the explicit General preference %j over legacy env', enabled => {
process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1'
process.env.CC_HAHA_AGENT_TEAMS_ENABLED = enabled ? '1' : '0'
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = enabled ? '0' : '1'
expect(isAgentSwarmsEnabled()).toBe(enabled)
})
test.each([undefined, 'ant'])('honors General opt-out with a forced launch (%j)', userType => {
if (userType) process.env.USER_TYPE = userType
process.env.CC_HAHA_AGENT_TEAMS_ENABLED = '0'
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'
process.argv.push('--agent-teams')
expect(isAgentSwarmsEnabled()).toBe(false)
expect(gate).not.toHaveBeenCalled()
})
test('retains the explicit CLI flag even when the environment opts out', () => {
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '0'
process.argv.push('--agent-teams')
expect(isAgentSwarmsEnabled()).toBe(true)
})
test.each(['0', 'false', ''])('does not enable an inactive host default %j', value => {
process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = value
expect(isAgentSwarmsEnabled()).toBe(false)
})
test('continues to respect the external killswitch with a host default', () => {
process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1'
gate.mockReturnValue(false)
expect(isAgentSwarmsEnabled()).toBe(false)
expect(gate).toHaveBeenCalledWith('tengu_amber_flint', true)
})
test('retains the Ant build bypass', () => {
process.env.USER_TYPE = 'ant'
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '0'
gate.mockReturnValue(false)
expect(isAgentSwarmsEnabled()).toBe(true)
expect(gate).not.toHaveBeenCalled()
})
test('preserves the persisted team preference through settings parsing', async () => {
const { SettingsSchema } = await import('./settings/types.js')
for (const agentTeamsEnabled of [true, false]) {
expect(SettingsSchema().parse({ agentTeamsEnabled }).agentTeamsEnabled).toBe(agentTeamsEnabled)
}
expect(SettingsSchema().safeParse({ agentTeamsEnabled: 'false' }).success).toBe(false)
})
})
+20 -5
View File
@@ -16,20 +16,35 @@ function isAgentTeamsFlagSet(): boolean {
* This is the single gate that should be checked everywhere teammates
* are referenced (prompts, code, tools isEnabled, UI, etc.).
*
* Ant builds: always enabled.
* A cc-haha General opt-out takes priority over all opt-ins.
* Ant builds: enabled unless the host explicitly opts out.
* External builds require both:
* 1. Opt-in via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var OR --agent-teams flag
* 1. Opt-in via the cc-haha host preference, legacy env, or --agent-teams
* 2. GrowthBook gate 'tengu_amber_flint' enabled (killswitch)
*/
export function isAgentSwarmsEnabled(): boolean {
// Ant: always on
// A saved General opt-out is authoritative even for forced/team child launches.
if (
process.env.CC_HAHA_AGENT_TEAMS_ENABLED !== undefined &&
!isEnvTruthy(process.env.CC_HAHA_AGENT_TEAMS_ENABLED)
) {
return false
}
// Ant: always on unless the host explicitly disabled teams
if (process.env.USER_TYPE === 'ant') {
return true
}
// External: require opt-in via env var or --agent-teams flag
// The host resolves General and legacy settings before launching. Standalone
// CLI sessions retain their upstream opt-in behavior.
const optIn = process.env.CC_HAHA_AGENT_TEAMS_ENABLED ??
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS ??
process.env.CC_HAHA_AGENT_TEAMS_DEFAULT
// External: require opt-in via env var, host default, or --agent-teams flag
if (
!isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) &&
!isEnvTruthy(optIn) &&
!isAgentTeamsFlagSet()
) {
return false
+30 -1
View File
@@ -3,10 +3,13 @@ import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { applySafeConfigEnvironmentVariables } from './managedEnv.js'
import { applyConfigEnvironmentVariables, applySafeConfigEnvironmentVariables } from './managedEnv.js'
let tmpDir: string
const originalEnv = {
CC_HAHA_AGENT_TEAMS_ENABLED: process.env.CC_HAHA_AGENT_TEAMS_ENABLED,
CC_HAHA_AGENT_TEAMS_DEFAULT: process.env.CC_HAHA_AGENT_TEAMS_DEFAULT,
CLAUDE_CODE_ENTRYPOINT: process.env.CLAUDE_CODE_ENTRYPOINT,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST,
CC_HAHA_LOCAL_ACCESS_TOKEN: process.env.CC_HAHA_LOCAL_ACCESS_TOKEN,
@@ -39,6 +42,9 @@ describe('managedEnv', () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'managed-env-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
delete process.env.CC_HAHA_AGENT_TEAMS_ENABLED
delete process.env.CC_HAHA_AGENT_TEAMS_DEFAULT
process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-cli'
delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
delete process.env.ANTHROPIC_BASE_URL
delete process.env.ANTHROPIC_API_KEY
@@ -58,6 +64,9 @@ describe('managedEnv', () => {
restoreEnv('CLAUDE_CONFIG_DIR')
restoreEnv('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST')
restoreEnv('CC_HAHA_LOCAL_ACCESS_TOKEN')
restoreEnv('CC_HAHA_AGENT_TEAMS_ENABLED')
restoreEnv('CC_HAHA_AGENT_TEAMS_DEFAULT')
restoreEnv('CLAUDE_CODE_ENTRYPOINT')
restoreEnv('ANTHROPIC_BASE_URL')
restoreEnv('ANTHROPIC_API_KEY')
restoreEnv('ANTHROPIC_AUTH_TOKEN')
@@ -68,6 +77,26 @@ describe('managedEnv', () => {
restoreEnv('CLAUDE_CODE_PROVIDER_MAX_OUTPUT_TOKENS')
})
test.each(['0', '1', undefined])('protects the General team preference %j through settings application', async (enabled) => {
await writeJson(path.join(tmpDir, 'cc-haha', 'settings.json'), {
env: {
CC_HAHA_AGENT_TEAMS_ENABLED: enabled === '1' ? '0' : '1',
CC_HAHA_AGENT_TEAMS_DEFAULT: '0',
},
})
if (enabled !== undefined) process.env.CC_HAHA_AGENT_TEAMS_ENABLED = enabled
process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1'
// OAuth and cron sessions can use sdk-cli without host-owned provider routing.
// Both the pre-trust and post-trust settings paths must preserve the choice.
applySafeConfigEnvironmentVariables()
expect(process.env.CC_HAHA_AGENT_TEAMS_ENABLED).toBe(enabled)
expect(process.env.CC_HAHA_AGENT_TEAMS_DEFAULT).toBe('1')
applyConfigEnvironmentVariables()
expect(process.env.CC_HAHA_AGENT_TEAMS_ENABLED).toBe(enabled)
expect(process.env.CC_HAHA_AGENT_TEAMS_DEFAULT).toBe('1')
})
test('starts a standalone provider proxy for CLI-only OpenAI-compatible providers', async () => {
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
activeId: 'agnes-provider',
+13 -4
View File
@@ -66,6 +66,13 @@ function withoutHostManagedProviderVars(
return out
}
// Internal launch controls are never supplied by settings.env. This also
// covers sdk-cli/OAuth sessions without host-managed provider routing.
const HOST_TEAM_ENV_KEYS = new Set([
'CC_HAHA_AGENT_TEAMS_ENABLED',
'CC_HAHA_AGENT_TEAMS_DEFAULT',
])
const HOST_OWNED_ENV_KEYS = new Set([
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
'CC_HAHA_LOCAL_ACCESS_TOKEN',
@@ -74,12 +81,14 @@ const HOST_OWNED_ENV_KEYS = new Set([
function withoutHostOwnedEnvVars(
env: Record<string, string> | undefined,
): Record<string, string> {
if (!env || !isEnvTruthy(process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST)) {
return env || {}
}
if (!env) return {}
const hostManagedProvider = isEnvTruthy(process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST)
return Object.fromEntries(
Object.entries(env).filter(([key]) => !HOST_OWNED_ENV_KEYS.has(key)),
Object.entries(env).filter(([key]) =>
!HOST_TEAM_ENV_KEYS.has(key) &&
!(hostManagedProvider && HOST_OWNED_ENV_KEYS.has(key)),
),
)
}
+4
View File
@@ -462,6 +462,10 @@ export const SettingsSchema = lazySchema(() =>
'Explicitly enable dynamic workflows. Only consulted when they are not ' +
'disabled; `disableWorkflows` and CLAUDE_CODE_DISABLE_WORKFLOWS win.',
),
agentTeamsEnabled: z
.boolean()
.optional()
.describe('Enable Agent Teams for new cc-haha managed sessions. Overrides the legacy team environment setting.'),
workflowKeywordTriggerEnabled: z
.boolean()
.optional()