refactor(open-build): remove Ant employee gates (#1576)

* refactor(open-build): remove Ant employee gates

* fix(open-build): address gate-removal review feedback

* fix(open-build): address follow-up review findings

* fix(hooks): remove stale remote fallback status

* fix(open-build): keep pending background tasks visible

* test(open-build): cover task footer hiding
This commit is contained in:
Bogdan
2026-06-10 09:01:26 +08:00
committed by GitHub
parent 62c2c5b62f
commit e6ce1037fe
49 changed files with 933 additions and 1229 deletions
+1 -12
View File
@@ -76,8 +76,6 @@ const featureFlags: Record<string, boolean> = {
// Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n)
const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs
const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g
const isAntEmployeeCallRe = /(?<!\bfunction\s)isAntEmployee\(\)/g
const isAntEmployeeConstRe = /\bIS_ANT_EMPLOYEE\b/g
const featureFlagTransformedFiles = new Set<string>()
const featureFlagPreprocessPlugin = {
@@ -88,22 +86,13 @@ const featureFlagPreprocessPlugin = {
if (!normalizedPath.includes('/src/')) return null
const raw = readFileSync(args.path, 'utf-8')
if (!raw.includes('feature(') && !raw.includes('isAntEmployee()') && !raw.includes('IS_ANT_EMPLOYEE')) return null
if (!raw.includes('feature(')) return null
let contents = raw
contents = contents.replace(featureImportRe, '')
contents = contents.replace(featureCallRe, (_match, name) =>
String((featureFlags as Record<string, boolean>)[name] ?? false),
)
contents = contents.replace(isAntEmployeeCallRe, 'false')
contents = contents.replace(isAntEmployeeConstRe, 'false')
// After replacing IS_ANT_EMPLOYEE → false, clean up the import:
// import { false, isAntEmployee } from ... → import { isAntEmployee } from ...
// And remove the dead export:
// export const false = false as const → (removed)
contents = contents.replace(/\{\s*false\s*,\s*isAntEmployee\s*\}/g, '{ isAntEmployee }')
contents = contents.replace(/\{\s*isAntEmployee\s*,\s*false\s*\}/g, '{ isAntEmployee }')
contents = contents.replace(/^export const false = false as const\s*\n?\s*/gm, '')
if (contents === raw) return null
+137
View File
@@ -0,0 +1,137 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { join, relative } from 'path'
import { expect, test } from 'bun:test'
const REPO_ROOT = join(import.meta.dir, '..')
const SOURCE_ROOTS = ['src', 'scripts']
const BANNED_PATTERNS = [
/\bisAntEmployee\b/,
/\bIS_ANT_EMPLOYEE\b/,
/utils\/buildConfig/,
] as const
const REMOVED_FILES = [
'src/utils/buildConfig.ts',
'src/utils/buildConfig.test.ts',
] as const
function collectFiles(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
if (entry === 'node_modules' || entry === 'dist') continue
files.push(...collectFiles(fullPath))
continue
}
if (/\.(ts|tsx)$/.test(entry)) {
files.push(fullPath)
}
}
return files
}
function findMatchingFunctionEnd(source: string, functionStart: number): number {
const bodyStart = source.indexOf('{', functionStart)
if (bodyStart === -1) {
throw new Error('Could not find function body start')
}
let braceDepth = 0
let stringQuote: '"' | "'" | '`' | null = null
let inLineComment = false
let inBlockComment = false
for (let index = bodyStart; index < source.length; index++) {
const current = source[index]
const next = source[index + 1]
if (inLineComment) {
if (current === '\n') inLineComment = false
continue
}
if (inBlockComment) {
if (current === '*' && next === '/') {
inBlockComment = false
index++
}
continue
}
if (stringQuote) {
if (current === '\\') {
index++
continue
}
if (current === stringQuote) stringQuote = null
continue
}
if (current === '/' && next === '/') {
inLineComment = true
index++
continue
}
if (current === '/' && next === '*') {
inBlockComment = true
index++
continue
}
if (current === '"' || current === "'" || current === '`') {
stringQuote = current
continue
}
if (current === '{') {
braceDepth++
continue
}
if (current === '}') {
braceDepth--
if (braceDepth === 0) return index + 1
}
}
throw new Error('Could not find function body end')
}
test('open build source does not reintroduce Ant employee gate helpers', () => {
const offenders: string[] = []
for (const filePath of REMOVED_FILES) {
expect(existsSync(join(REPO_ROOT, filePath))).toBe(false)
}
for (const root of SOURCE_ROOTS) {
for (const filePath of collectFiles(join(REPO_ROOT, root))) {
if (filePath === import.meta.path) continue
const contents = readFileSync(filePath, 'utf8')
if (BANNED_PATTERNS.some(pattern => pattern.test(contents))) {
offenders.push(relative(REPO_ROOT, filePath))
}
}
}
expect(offenders).toEqual([])
})
test('initial plan messages do not seed pending plan verification state', () => {
const replSource = readFileSync(join(REPO_ROOT, 'src/screens/REPL.tsx'), 'utf8')
const initialMessageHandlerStart = replSource.indexOf(
'async function processInitialMessage',
)
expect(initialMessageHandlerStart).toBeGreaterThan(-1)
const initialMessageHandlerEnd = findMatchingFunctionEnd(
replSource,
initialMessageHandlerStart,
)
expect(initialMessageHandlerEnd).toBeGreaterThan(initialMessageHandlerStart)
expect(
replSource.slice(initialMessageHandlerStart, initialMessageHandlerEnd),
).not.toContain('pendingPlanVerification')
})
-3
View File
@@ -5,18 +5,15 @@ import { Text } from '../ink.js';
import { getGlobalConfig } from '../utils/config.js';
import { getRainbowColor } from '../utils/thinking.js';
import { isBuddyEnabled } from './feature.js';
import { isAntEmployee } from '../utils/buildConfig.js';
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
// Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean {
if (isAntEmployee()) return true;
const d = new Date();
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7;
}
export function isBuddyLive(): boolean {
if (isAntEmployee()) return true;
const d = new Date();
return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3;
}
-6
View File
@@ -5,8 +5,6 @@ import { MCPReconnect } from '../../components/mcp/MCPReconnect.js';
import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js';
import { useAppState } from '../../state/AppState.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { PluginSettings } from '../plugin/PluginSettings.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
// TODO: This is a hack to get the context value from toggleMcpServer (useContext only works in a component)
// Ideally, all MCP state and functions would be in global state.
@@ -77,9 +75,5 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
}
}
// Redirect base /mcp command to /plugins installed tab for ant users
if (isAntEmployee()) {
return <PluginSettings onComplete={onDone} args="manage" showMcpRedirectMessage />;
}
return <MCPSettings onComplete={onDone} />;
}
@@ -11,7 +11,6 @@ import { maybeMarkProjectOnboardingComplete } from '../../projectOnboardingState
import type { ToolUseContext } from '../../Tool.js';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
import { backupTerminalPreferences, checkAndRestoreTerminalBackup, getTerminalPlistPath, markTerminalSetupComplete } from '../../utils/appleTerminalBackup.js';
import { setupShellCompletion } from '../../utils/completionCache.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { env } from '../../utils/env.js';
import { isFsInaccessible } from '../../utils/errors.js';
@@ -20,7 +19,6 @@ import { addItemToJSONCArray, safeParseJSONC } from '../../utils/json.js';
import { logError } from '../../utils/log.js';
import { getPlatform } from '../../utils/platform.js';
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
const EOL = '\n';
// Terminals that natively support CSI u / Kitty keyboard protocol
@@ -119,10 +117,6 @@ export async function setupTerminal(theme: ThemeName): Promise<string> {
});
maybeMarkProjectOnboardingComplete();
// Install shell completions (internal-only, since the completion command is internal-only)
if (isAntEmployee()) {
result += await setupShellCompletion(theme);
}
return result;
}
export function isShiftEnterKeyBindingInstalled(): boolean {
+2 -6
View File
@@ -23,17 +23,13 @@ import { addMarketplaceSource, clearMarketplacesCache, loadKnownMarketplacesConf
import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js';
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js';
import { installSelectedPlugins } from '../../utils/plugins/pluginStartupCheck.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
// Marketplace and plugin identifiers - varies by user type
const INTERNAL_MARKETPLACE_NAME = 'claude-code-marketplace';
const INTERNAL_MARKETPLACE_REPO = 'anthropics/claude-code-marketplace';
const OFFICIAL_MARKETPLACE_REPO = 'anthropics/claude-plugins-official';
function getMarketplaceName(): string {
return isAntEmployee() ? INTERNAL_MARKETPLACE_NAME : OFFICIAL_MARKETPLACE_NAME;
return OFFICIAL_MARKETPLACE_NAME;
}
function getMarketplaceRepo(): string {
return isAntEmployee() ? INTERNAL_MARKETPLACE_REPO : OFFICIAL_MARKETPLACE_REPO;
return OFFICIAL_MARKETPLACE_REPO;
}
function getPluginId(): string {
return `thinkback@${getMarketplaceName()}`;
+2 -11
View File
@@ -1,4 +1,3 @@
import { readFileSync } from 'fs';
import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js';
import type { Command } from '../commands.js';
import { DIAMOND_OPEN } from '../constants/figures.js';
@@ -16,7 +15,6 @@ import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js';
import { updateTaskState } from '../utils/task/framework.js';
import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js';
import { pollForApprovedExitPlanMode, UltraplanPollError } from '../utils/ultraplan/ccrSession.js';
import { isAntEmployee } from '../utils/buildConfig.js';
// TODO(prod-hardening): OAuth token may go stale over the 30min poll;
// consider refresh.
@@ -48,14 +46,7 @@ const _rawPrompt = require('../utils/ultraplan/prompt.txt');
/* eslint-enable @typescript-eslint/no-require-imports */
const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default).trimEnd();
// Dev-only prompt override resolved eagerly at module load.
// Gated to ant builds (USER_TYPE is a build-time define,
// so the override path is DCE'd from external builds).
// Shell-set env only, so top-level process.env read is fine
// — settings.env never injects this.
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- internal-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
const ULTRAPLAN_INSTRUCTIONS: string = isAntEmployee() && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS;
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
const ULTRAPLAN_INSTRUCTIONS: string = DEFAULT_INSTRUCTIONS;
/**
* Assemble the initial CCR user message. seedPlan and blurb stay outside the
@@ -464,7 +455,7 @@ export default {
name: 'ultraplan',
description: `~1030 min · OpenClaude on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`,
argumentHint: '<prompt>',
isEnabled: () => isAntEmployee(),
isEnabled: () => false,
load: () => Promise.resolve({
call
})
+1 -3
View File
@@ -3,11 +3,9 @@ import * as React from 'react';
import { useState } from 'react';
import { getSlowOperations } from '../bootstrap/state.js';
import { Text, useInterval } from '../ink.js';
import { isAntEmployee } from '../utils/buildConfig.js';
// Show DevBar for dev builds or all ants
function shouldShowDevBar(): boolean {
return "production" === 'development' || isAntEmployee();
return false;
}
export function DevBar() {
const $ = _c(5);
@@ -14,7 +14,6 @@ import { submitTranscriptShare } from './submitTranscriptShare.js';
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
import { useSurveyState } from './useSurveyState.js';
import type { FeedbackSurveyResponse } from './utils.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
const HIDE_THANKS_AFTER_MS = 3000;
const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell';
const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event';
@@ -75,21 +74,7 @@ export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActi
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
}, []);
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
if (!isAntEmployee()) {
return false;
}
if (selected_0 !== 'bad' && selected_0 !== 'good') {
return false;
}
if (getGlobalConfig().transcriptShareDismissed) {
return false;
}
if (!isPolicyAllowed('allow_product_feedback')) {
return false;
}
return true;
}, []);
const shouldShowTranscriptPrompt = useCallback(() => false, []);
const onTranscriptPromptShown = useCallback((appearanceId_1: string) => {
logEvent(MEMORY_SURVEY_EVENT, {
event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+2 -13
View File
@@ -9,7 +9,6 @@ import { getCwd } from '../../utils/cwd.js';
import { formatRelativeTimeAgo } from '../../utils/format.js';
import { getReleaseSectionHeaderTitle, isReleaseSectionHeader } from '../../utils/releaseNotes.js';
import type { FeedConfig, FeedLine } from './Feed.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
export function createRecentActivityFeed(activities: LogOption[]): FeedConfig {
const lines: FeedLine[] = activities.map(log => {
const time = formatRelativeTimeAgo(log.modified);
@@ -28,15 +27,6 @@ export function createRecentActivityFeed(activities: LogOption[]): FeedConfig {
}
export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
const lines: FeedLine[] = releaseNotes.map(note => {
if (isAntEmployee()) {
const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/);
if (match) {
return {
timestamp: match[1],
text: match[2] || ''
};
}
}
if (isReleaseSectionHeader(note)) {
return {
text: `${getReleaseSectionHeaderTitle(note)}:`
@@ -46,12 +36,11 @@ export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
text: note
};
});
const emptyMessage = isAntEmployee() ? 'Unable to fetch latest claude-cli-internal commits' : 'Check /release-notes for recent updates';
return {
title: isAntEmployee() ? "OpenClaude Updates [internal-only: Latest CC commits]" : "OpenClaude Updates",
title: "OpenClaude Updates",
lines,
footer: lines.length > 0 ? '/release-notes for more' : undefined,
emptyMessage
emptyMessage: 'Check /release-notes for recent updates'
};
}
export function createProjectOnboardingFeed(steps: Step[]): FeedConfig {
+1 -34
View File
@@ -1,37 +1,4 @@
import * as React from 'react';
import { useMemoryUsage } from '../hooks/useMemoryUsage.js';
import { Box, Text } from '../ink.js';
import { formatFileSize } from '../utils/format.js';
import { isAntEmployee } from '../utils/buildConfig.js';
export function MemoryUsageIndicator(): React.ReactNode {
// Ant-only: the /heapdump link is an internal debugging aid. Gating before
// the hook means the 10s polling interval is never set up in external builds.
// USER_TYPE is a build-time constant, so the hook call below is either always
// reached or dead-code-eliminated — never conditional at runtime.
if (!isAntEmployee()) {
return null;
}
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant
const memoryUsage = useMemoryUsage();
if (!memoryUsage) {
return null;
}
const {
heapUsed,
status
} = memoryUsage;
// Only show indicator when memory usage is high or critical
if (status === 'normal') {
return null;
}
const formattedSize = formatFileSize(heapUsed);
const color = status === 'critical' ? 'error' : 'warning';
return <Box>
<Text color={color} wrap="truncate">
High memory usage ({formattedSize}) · /heapdump
</Text>
</Box>;
return null;
}
-9
View File
@@ -29,7 +29,6 @@ import { count } from '../utils/array.js';
import { formatRelativeTimeAgo, truncate } from '../utils/format.js';
import type { Theme } from '../utils/theme.js';
import { Divider } from './design-system/Divider.js';
import { isAntEmployee } from '../utils/buildConfig.js';
type RestoreOption = 'both' | 'conversation' | 'code' | 'summarize' | 'summarize_up_to' | 'nevermind';
function isSummarizeOption(option: RestoreOption | null): option is 'summarize' | 'summarize_up_to' {
return option === 'summarize' || option === 'summarize_up_to';
@@ -120,14 +119,6 @@ export function MessageSelector({
...summarizeInputProps,
onChange: setSummarizeFromFeedback
});
if (isAntEmployee()) {
baseOptions.push({
value: 'summarize_up_to',
label: 'Summarize up to here',
...summarizeInputProps,
onChange: setSummarizeUpToFeedback
});
}
baseOptions.push({
value: 'nevermind',
label: 'Never mind'
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test'
import type { AutoUpdaterResult } from '../utils/autoUpdater.js'
import { shouldRenderNativeAutoUpdater } from './NativeAutoUpdater.js'
describe('shouldRenderNativeAutoUpdater', () => {
test('renders install failures even when no update version is available', () => {
const result: AutoUpdaterResult = {
status: 'install_failed',
version: null,
}
expect(shouldRenderNativeAutoUpdater(result, false, {})).toBe(true)
})
test('renders update progress only with complete version info', () => {
expect(
shouldRenderNativeAutoUpdater(null, true, {
current: '1.0.0',
latest: '1.1.0',
}),
).toBe(true)
expect(
shouldRenderNativeAutoUpdater(null, true, {
current: '1.0.0',
}),
).toBe(false)
})
test('does not render with no update result and no active check', () => {
expect(shouldRenderNativeAutoUpdater(null, false, {})).toBe(false)
})
})
+10 -22
View File
@@ -7,12 +7,9 @@ import { useInterval } from 'usehooks-ts';
import { useUpdateNotification } from '../hooks/useUpdateNotification.js';
import { Box, Text } from '../ink.js';
import type { AutoUpdaterResult } from '../utils/autoUpdater.js';
import { getMaxVersion, getMaxVersionMessage } from '../utils/autoUpdater.js';
import { isAutoUpdaterDisabled } from '../utils/config.js';
import { installLatest } from '../utils/nativeInstaller/index.js';
import { gt } from '../utils/semver.js';
import { getInitialSettings } from '../utils/settings/settings.js';
import { isAntEmployee } from '../utils/buildConfig.js';
/**
* Categorize error messages for analytics
@@ -49,6 +46,15 @@ type Props = {
showSuccessMessage: boolean;
verbose: boolean;
};
type NativeAutoUpdaterVersions = {
current?: string | null;
latest?: string | null;
};
export function shouldRenderNativeAutoUpdater(autoUpdaterResult: AutoUpdaterResult | null, isUpdating: boolean, versions: NativeAutoUpdaterVersions): boolean {
const hasUpdateResult = autoUpdaterResult?.status === 'success' || autoUpdaterResult?.status === 'install_failed';
const hasVersionInfo = !!versions.current && !!versions.latest;
return hasUpdateResult || (isUpdating && hasVersionInfo);
}
export function NativeAutoUpdater({
isUpdating,
onChangeIsUpdating,
@@ -61,7 +67,6 @@ export function NativeAutoUpdater({
current?: string | null;
latest?: string | null;
}>({});
const [maxVersionIssue, setMaxVersionIssue] = useState<string | null>(null);
const updateSemver = useUpdateNotification(autoUpdaterResult?.version);
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest';
@@ -75,10 +80,6 @@ export function NativeAutoUpdater({
if (isUpdatingRef.current) {
return;
}
if ("production" === 'test' || "production" === 'development') {
logForDebugging('NativeAutoUpdater: Skipping update check in test/dev environment');
return;
}
if (isAutoUpdaterDisabled()) {
return;
}
@@ -88,12 +89,6 @@ export function NativeAutoUpdater({
// Log the start of an auto-update check for funnel analysis
logEvent('tengu_native_auto_updater_start', {});
try {
// Check if current version is above the max allowed version
const maxVersion = await getMaxVersion();
if (maxVersion && gt(MACRO.VERSION, maxVersion)) {
const msg = await getMaxVersionMessage();
setMaxVersionIssue(msg ?? 'affects your version');
}
const result = await installLatest(channel);
const currentVersion = MACRO.VERSION;
const latencyMs = Date.now() - startTime;
@@ -161,13 +156,10 @@ export function NativeAutoUpdater({
// Check every 30 minutes
useInterval(checkForUpdates, 30 * 60 * 1000);
const hasUpdateResult = !!autoUpdaterResult?.version;
const hasVersionInfo = !!versions.current && !!versions.latest;
// Show the component when:
// - warning banner needed (above max version), or
// - there's an update result to display (success/error), or
// - actively checking and we have version info to show
const shouldRender = !!maxVersionIssue || hasUpdateResult || isUpdating && hasVersionInfo;
const shouldRender = shouldRenderNativeAutoUpdater(autoUpdaterResult, isUpdating, versions);
if (!shouldRender) {
return null;
}
@@ -185,9 +177,5 @@ export function NativeAutoUpdater({
{autoUpdaterResult?.status === 'install_failed' && <Text color="error" wrap="truncate">
Auto-update failed &middot; Try <Text bold>/status</Text>
</Text>}
{maxVersionIssue && isAntEmployee() && <Text color="warning">
Known issue: {maxVersionIssue} &middot; Run{' '}
<Text bold>claude rollback --safe</Text> to downgrade
</Text>}
</Box>;
}
+6 -35
View File
@@ -46,7 +46,7 @@ import { enterTeammateView, exitTeammateView, stopOrDismissAgent } from '../../s
import type { ToolPermissionContext } from '../../Tool.js';
import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js';
import { isPanelAgentTask, type LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
import { type LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
import { isBackgroundTask } from '../../tasks/types.js';
import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js';
import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js';
@@ -109,7 +109,7 @@ import { QuickOpenDialog } from '../QuickOpenDialog.js';
import TextInput from '../TextInput.js';
import { ThinkingToggle } from '../ThinkingToggle.js';
import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js';
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
import { countVisibleBackgroundTasks, shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
import { TeamsDialog } from '../teams/TeamsDialog.js';
import VimTextInput from '../VimTextInput.js';
import { detectModeEntry, getModeFromInput, getValueFromInput } from './inputModes.js';
@@ -124,7 +124,6 @@ import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js';
import { useShowFastIconHint } from './useShowFastIconHint.js';
import { useSwarmBanner } from './useSwarmBanner.js';
import { isNonSpacePrintable, isVimModeEnabled } from './utils.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
type Props = {
debug: boolean;
ideSelection: IDESelection | undefined;
@@ -305,9 +304,7 @@ function PromptInput({
// the pill returns null for implicit-and-not-reconnecting, so nav must too,
// otherwise bridge becomes an invisible selection stop.
const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting);
// Tmux pill (internal-only) — visible when there's an active tungsten session
const hasTungstenSession = useAppState(s => isAntEmployee() && s.tungstenActiveSession !== undefined);
const tmuxFooterVisible = isAntEmployee() && hasTungstenSession;
const tmuxFooterVisible = false;
// WebBrowser pill — visible when a browser is open
const bagelFooterVisible = useAppState(s => false);
const teamContext = useAppState(s => s.teamContext);
@@ -403,7 +400,7 @@ function PromptInput({
// exist. When only local_agent tasks are running (coordinator/fork mode), the
// pill is absent, so the -1 sentinel would leave nothing visually selected.
// In that case, skip -1 and treat 0 as the minimum selectable index.
const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !(isAntEmployee() && isPanelAgentTask(t))), [tasks]);
const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t)), [tasks]);
const minCoordinatorIndex = hasBgTaskPill ? -1 : 0;
// Clamp index when tasks complete and the list shrinks beneath the cursor
useEffect(() => {
@@ -468,11 +465,8 @@ function PromptInput({
// Which pills render below the input box. Order here IS the nav order
// (down/right = forward, up/left = back). Selection lives in AppState so
// pills rendered outside PromptInput (CompanionSprite) can read focus.
const runningTaskCount = useMemo(() => count(Object.values(tasks), t => t.status === 'running'), [tasks]);
// Panel shows retained-completed agents too (getVisibleAgentTasks), so the
// pill must stay navigable whenever the panel has rows — not just when
// something is running.
const tasksFooterVisible = (runningTaskCount > 0 || isAntEmployee() && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree);
const backgroundTaskCount = useMemo(() => countVisibleBackgroundTasks(tasks), [tasks]);
const tasksFooterVisible = backgroundTaskCount > 0 && !shouldHideTasksFooter(tasks, showSpinnerTree);
const teamsFooterVisible = cachedTeams.length > 0;
const footerItems = useMemo(() => [tasksFooterVisible && 'tasks', tmuxFooterVisible && 'tmux', bagelFooterVisible && 'bagel', teamsFooterVisible && 'teams', bridgeFooterVisible && 'bridge', companionFooterVisible && 'companion'].filter(Boolean) as FooterItem[], [tasksFooterVisible, tmuxFooterVisible, bagelFooterVisible, teamsFooterVisible, bridgeFooterVisible, companionFooterVisible]);
@@ -1816,21 +1810,9 @@ function PromptInput({
// selected — its useInput is inactive, so this is the only path.
useKeybindings({
'footer:up': () => {
// ↑ scrolls within the coordinator task list before leaving the pill
if (tasksSelected && isAntEmployee() && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) {
setCoordinatorTaskIndex(prev => prev - 1);
return;
}
navigateFooter(-1, true);
},
'footer:down': () => {
// ↓ scrolls within the coordinator task list, never leaves the pill
if (tasksSelected && isAntEmployee() && coordinatorTaskCount > 0) {
if (coordinatorTaskIndex < coordinatorTaskCount - 1) {
setCoordinatorTaskIndex(prev => prev + 1);
}
return;
}
if (tasksSelected && !isTeammateMode) {
setShowBashesDialog(true);
selectFooterItem(null);
@@ -1887,17 +1869,6 @@ function PromptInput({
}
}
break;
case 'tmux':
if (isAntEmployee()) {
setAppState(prev => prev.tungstenPanelAutoHidden ? {
...prev,
tungstenPanelAutoHidden: false
} : {
...prev,
tungstenPanelVisible: !(prev.tungstenPanelVisible ?? true)
});
}
break;
case 'bagel':
break;
case 'teams':
@@ -16,14 +16,12 @@ import type { Message } from '../../types/message.js';
import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js';
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import { isUndercover } from '../../utils/undercover.js';
import { CoordinatorTaskPanel, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js';
import { useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js';
import { getLastAssistantMessageId, StatusLine, statusLineShouldDisplay } from '../StatusLine.js';
import { Notifications } from './Notifications.js';
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js';
import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js';
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
type Props = {
apiKeyStatus: VerificationStatus;
debug: boolean;
@@ -144,11 +142,9 @@ function PromptInputFooter({
</Box>
<Box flexShrink={1} gap={1}>
{isFullscreen ? null : <Notifications apiKeyStatus={apiKeyStatus} autoUpdaterResult={autoUpdaterResult} debug={debug} isAutoUpdating={isAutoUpdating} verbose={verbose} messages={messages} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} ideSelection={ideSelection} mcpClients={mcpClients} isInputWrapped={isInputWrapped} isNarrow={isNarrow} />}
{isAntEmployee() && isUndercover() && <Text dimColor>undercover</Text>}
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
</Box>
</Box>
{isAntEmployee() && <CoordinatorTaskPanel />}
</>;
}
export default memo(PromptInputFooter);
@@ -16,10 +16,8 @@ import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
import { isDefaultMode, permissionModeSymbol, permissionModeTitle, getModeColor } from '../../utils/permissions/PermissionMode.js';
import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js';
import { isBackgroundTask } from '../../tasks/types.js';
import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js';
import { count } from '../../utils/array.js';
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
import { countVisibleBackgroundTasks, shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
import { TeamStatus } from '../teams/TeamStatus.js';
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js';
@@ -41,7 +39,6 @@ import { useHasSelection, useSelection } from '../../ink/hooks/use-selection.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { getPlatform } from '../../utils/platform.js';
import { PrBadge } from '../PrBadge.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
// Dead code elimination: conditional import for proactive mode
/* eslint-disable @typescript-eslint/no-require-imports */
@@ -198,7 +195,7 @@ export function PromptInputFooterLeftSide(t0) {
const t4 = !suppressHint && !showVim;
let t5;
if ($[13] !== isLoading || $[14] !== mode || $[15] !== onOpenTasksDialog || $[16] !== t4 || $[17] !== tasksSelected || $[18] !== teammateFooterIndex || $[19] !== teamsSelected || $[20] !== tmuxSelected || $[21] !== toolPermissionContext) {
t5 = <ModeIndicator mode={mode} toolPermissionContext={toolPermissionContext} showHint={t4} isLoading={isLoading} tasksSelected={tasksSelected} teamsSelected={teamsSelected} teammateFooterIndex={teammateFooterIndex} tmuxSelected={tmuxSelected} onOpenTasksDialog={onOpenTasksDialog} />;
t5 = <ModeIndicator mode={mode} toolPermissionContext={toolPermissionContext} showHint={t4} isLoading={isLoading} tasksSelected={tasksSelected} teamsSelected={teamsSelected} teammateFooterIndex={teammateFooterIndex} onOpenTasksDialog={onOpenTasksDialog} />;
$[13] = isLoading;
$[14] = mode;
$[15] = onOpenTasksDialog;
@@ -231,7 +228,6 @@ type ModeIndicatorProps = {
isLoading: boolean;
tasksSelected: boolean;
teamsSelected: boolean;
tmuxSelected: boolean;
teammateFooterIndex?: number;
onOpenTasksDialog?: (taskId?: string) => void;
};
@@ -242,7 +238,6 @@ function ModeIndicator({
isLoading,
tasksSelected,
teamsSelected,
tmuxSelected,
teammateFooterIndex,
onOpenTasksDialog
}: ModeIndicatorProps): React.ReactNode {
@@ -261,7 +256,6 @@ function ModeIndicator({
const expandedView = useAppState(s_3 => s_3.expandedView);
const showSpinnerTree = expandedView === 'teammates';
const prStatus = usePrStatus(isLoading, isPrStatusEnabled());
const hasTmuxSession = useAppState(s_4 => isAntEmployee() && s_4.tungstenActiveSession !== undefined);
const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL);
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
@@ -275,7 +269,7 @@ function ModeIndicator({
const selGetState = useSelection().getState;
const hasNextTick = nextTickAt !== null;
const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false;
const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !(isAntEmployee() && isPanelAgentTask(t))), [tasks]);
const backgroundTaskCount = useMemo(() => countVisibleBackgroundTasks(tasks), [tasks]);
const tasksV2 = useTasksV2();
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0;
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
@@ -323,7 +317,7 @@ function ModeIndicator({
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
const isViewingTeammate = viewSelectionMode === 'viewing-agent' && viewedTask?.type === 'in_process_teammate';
const isViewingCompletedTeammate = isViewingTeammate && viewedTask != null && viewedTask.status !== 'running';
const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate;
const hasBackgroundTasks = backgroundTaskCount > 0 || isViewingTeammate;
// Count primary items (permission mode or coordinator mode, background tasks, and teams)
const primaryItemCount = (isCoordinator || hasActiveMode ? 1 : 0) + (hasBackgroundTasks ? 1 : 0) + (hasTeams ? 1 : 0);
@@ -365,8 +359,7 @@ function ModeIndicator({
// BackgroundTaskStatus is NOT in parts — it renders as a Box sibling so
// its click-target Box isn't nested inside the <Text wrap="truncate">
// wrapper (reconciler throws on Box-in-Text).
// Tmux pill (internal-only) — appears right after tasks in nav order
...(isAntEmployee() && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []), ...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />] : [])];
...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []), ...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />] : [])];
// Check if any in-process teammates exist (for hint text cycling)
const hasAnyInProcessTeammates = Object.values(tasks).some(t_2 => t_2.type === 'in_process_teammate' && t_2.status === 'running');
@@ -399,9 +392,6 @@ function ModeIndicator({
</Box>;
}
// Add "↓ to manage tasks" hint when panel has visible rows
const hasCoordinatorTasks = isAntEmployee() && getVisibleAgentTasks(tasks).length > 0;
// Tasks pill renders as a Box sibling (not a parts entry) so its
// click-target Box isn't nested inside <Text wrap="truncate"> — the
// reconciler throws on Box-in-Text. Computed here so the empty-checks
@@ -448,7 +438,7 @@ function ModeIndicator({
hold {voiceKeyShortcut} to speak
</Text>);
}
if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) {
if (tasksPart && showHint && !hasTeams) {
parts.push(<Text dimColor key="manage-tasks">
{tasksSelected ? <KeyboardShortcutHint shortcut="Enter" action="view tasks" /> : <KeyboardShortcutHint shortcut="↓" action="manage" />}
</Text>);
+1 -24
View File
@@ -49,7 +49,6 @@ import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeEnabled, getFastModeModel, isFastModeSupportedByModel } from '../../utils/fastMode.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import { getDefaultPermissionModeOptions } from '../../utils/permissions/defaultPermissionModeOptions.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
type Props = {
onClose: (result?: string, options?: {
display?: CommandResultDisplay;
@@ -431,29 +430,7 @@ export function Config({
});
}
}] : []),
// Speculation toggle (internal-only)
...(isAntEmployee() ? [{
id: 'speculationEnabled',
label: 'Speculative execution',
value: globalConfig.speculationEnabled ?? true,
type: 'boolean' as const,
onChange(enabled_2: boolean) {
saveGlobalConfig(current_1 => {
if (current_1.speculationEnabled === enabled_2) return current_1;
return {
...current_1,
speculationEnabled: enabled_2
};
});
setGlobalConfig({
...getGlobalConfig(),
speculationEnabled: enabled_2
});
logEvent('tengu_speculation_setting_changed', {
enabled: enabled_2
});
}
}] : []), ...(isFileCheckpointingAvailable ? [{
...(isFileCheckpointingAvailable ? [{
id: 'fileCheckpointingEnabled',
label: 'Rewind code (checkpoints)',
value: globalConfig.fileCheckpointingEnabled,
-20
View File
@@ -24,7 +24,6 @@ import { getTheme, themeColorToAnsi } from '../utils/theme.js';
import { Pane } from './design-system/Pane.js';
import { Tab, Tabs, useTabHeaderFocus } from './design-system/Tabs.js';
import { Spinner } from './Spinner.js';
import { isAntEmployee } from '../utils/buildConfig.js';
function formatPeakDay(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
@@ -519,19 +518,6 @@ function OverviewTab({
</Text>
</Box>
</Box>
{/* Speculation time saved (internal-only) */}
{isAntEmployee() && stats.totalSpeculationTimeSavedMs > 0 && <Box flexDirection="row" gap={4}>
<Box flexDirection="column" width={28}>
<Text wrap="truncate">
Speculation saved:{' '}
<Text color="claude">
{formatDuration(stats.totalSpeculationTimeSavedMs)}
</Text>
</Text>
</Box>
</Box>}
{/* Shot stats (internal-only) */}
{shotStatsData && <>
<Box marginTop={1}>
@@ -1164,12 +1150,6 @@ function renderOverviewToAnsi(stats: ClaudeCodeStats): string[] {
const peakHourVal = stats.peakActivityHour !== null ? `${stats.peakActivityHour}:00-${stats.peakActivityHour + 1}:00` : 'N/A';
lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal));
// Speculation time saved (internal-only)
if (isAntEmployee() && stats.totalSpeculationTimeSavedMs > 0) {
const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH);
lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs)));
}
// Shot stats (internal-only)
if (feature('SHOT_STATS') && stats.shotDistribution) {
const dist = stats.shotDistribution;
+1 -2
View File
@@ -28,7 +28,6 @@ import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { count } from '../../utils/array.js';
import { plural } from '../../utils/stringUtils.js';
import { Divider } from '../design-system/Divider.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
type Props = {
tools: Tools;
initialTools: string[] | undefined;
@@ -59,7 +58,7 @@ function getToolBuckets(): ToolBuckets {
},
EXECUTION: {
name: 'Execution tools',
toolNames: new Set([BashTool.name, isAntEmployee() ? TungstenTool.name : undefined].filter(n => n !== undefined))
toolNames: new Set([BashTool.name])
},
MCP: {
name: 'MCP tools',
@@ -28,7 +28,6 @@ import FullWidthRow from '../design-system/FullWidthRow.js';
import { FilePathLink } from '../FilePathLink.js';
import { feature } from 'bun:bundle';
import { useSelectedMessageBg } from '../messageActions.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
type Props = {
addMargin: boolean;
attachment: Attachment;
@@ -110,12 +109,8 @@ export function AttachmentMessage({
if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
if (attachment.type === 'skill_discovery') {
if (attachment.skills.length === 0) return null;
// Ant users get shortIds inline so they can /skill-feedback while the
// turn is still fresh. External users (when this un-gates) just see
// names — shortId is undefined outside ant builds anyway.
const names = attachment.skills.map(s => s.shortId ? `${s.name} [${s.shortId}]` : s.name).join(', ');
const firstId = attachment.skills[0]?.shortId;
const hint = isAntEmployee() && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` : '';
const hint = '';
return <Line>
<Text bold>{attachment.skills.length}</Text> relevant{' '}
{plural(attachment.skills.length, 'skill')}: {names}
@@ -365,9 +360,6 @@ function TaskStatusMessage(t0) {
const {
attachment
} = t0;
if (false && attachment.status === "killed") {
return null;
}
if (isAgentSwarmsEnabled() && attachment.taskType === "in_process_teammate") {
let t1;
if ($[0] !== attachment) {
@@ -0,0 +1,61 @@
import { describe, expect, test } from 'bun:test'
import type { TaskState } from '../../tasks/types.js'
import {
countVisibleBackgroundTasks,
shouldHideTasksFooter,
} from './taskStatusUtils.js'
function task(
status: string,
isBackgrounded = true,
type: TaskState['type'] = 'local_bash',
): TaskState {
return {
id: `${type}-${status}-${String(isBackgrounded)}`,
type,
status,
isBackgrounded,
} as unknown as TaskState
}
describe('countVisibleBackgroundTasks', () => {
test('counts running and pending tasks that render in the background task pill', () => {
const tasks = {
running: task('running'),
pending: task('pending'),
completed: task('completed'),
foreground: task('running', false),
}
expect(countVisibleBackgroundTasks(tasks)).toBe(2)
})
})
describe('shouldHideTasksFooter', () => {
test('hides spinner-tree teammate-only background tasks', () => {
const tasks = {
teammateRunning: task('running', true, 'in_process_teammate'),
teammatePending: task('pending', true, 'in_process_teammate'),
}
expect(shouldHideTasksFooter(tasks, true)).toBe(true)
})
test('shows spinner-tree footer when any non-teammate background task is visible', () => {
const tasks = {
teammate: task('running', true, 'in_process_teammate'),
shell: task('pending'),
}
expect(shouldHideTasksFooter(tasks, true)).toBe(false)
})
test('does not hide footer when no background tasks are visible', () => {
const tasks = {
completed: task('completed'),
foreground: task('running', false),
}
expect(shouldHideTasksFooter(tasks, true)).toBe(false)
})
})
+14 -5
View File
@@ -9,7 +9,6 @@ import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js';
import { isBackgroundTask, type TaskState } from 'src/tasks/types.js';
import type { DeepImmutable } from 'src/types/utils.js';
import { summarizeRecentActivities } from 'src/utils/collapseReadSearch.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
/**
* Returns true if the given task status represents a terminal (finished) state.
@@ -82,14 +81,24 @@ export function describeTeammateActivity(t: DeepImmutable<InProcessTeammateTaskS
return (t.progress?.recentActivities && summarizeRecentActivities(t.progress.recentActivities)) ?? t.progress?.lastActivity?.activityDescription ?? 'working';
}
export function countVisibleBackgroundTasks(tasks: {
[taskId: string]: TaskState;
}): number {
let backgroundTaskCount = 0;
for (const task of Object.values(tasks)) {
if (isBackgroundTask(task)) {
backgroundTaskCount += 1;
}
}
return backgroundTaskCount;
}
/**
* Returns true when BackgroundTaskStatus would render nothing because the
* spinner tree is active and every visible background task is an in-process
* teammate (teammates are shown in the spinner tree instead).
*
* Uses the same task filtering as BackgroundTaskStatus: `isBackgroundTask()`
* plus exclusion of panel-managed agent tasks for ants (those are shown
* by CoordinatorTaskPanel).
* Uses the same task filtering as BackgroundTaskStatus.
*/
export function shouldHideTasksFooter(tasks: {
[taskId: string]: TaskState;
@@ -97,7 +106,7 @@ export function shouldHideTasksFooter(tasks: {
if (!showSpinnerTree) return false;
let hasVisibleTask = false;
for (const t of Object.values(tasks) as TaskState[]) {
if (!isBackgroundTask(t) || isAntEmployee() && isPanelAgentTask(t)) {
if (!isBackgroundTask(t)) {
continue;
}
hasVisibleTask = true;
+36 -340
View File
@@ -33,7 +33,7 @@ import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js';
import { addToHistory } from './history.js';
import type { Root } from './ink.js';
import { launchRepl } from './replLauncher.js';
import { hasGrowthBookEnvOverride, initializeGrowthBook, refreshGrowthBookAfterAuthChange } from './services/analytics/growthbook.js';
import { refreshGrowthBookAfterAuthChange } from './services/analytics/growthbook.js';
import { fetchBootstrapData } from './services/api/bootstrap.js';
import { refreshStartupDiscoveryForActiveRoute } from './integrations/discoveryService.js';
import { prefetchOllamaModels } from './utils/model/ollamaModels.js';
@@ -49,7 +49,6 @@ import { getTools } from './tools.js';
import { canUserConfigureAdvisor, getInitialAdvisorSetting, isAdvisorEnabled, isValidAdvisorModel, modelSupportsAdvisor } from './utils/advisor.js';
import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js';
import { count, uniq } from './utils/array.js';
import { installAsciicastRecorder } from './utils/asciicast.js';
import { getSubscriptionType, isClaudeAISubscriber, prefetchAwsCredentialsAndBedRockInfoIfSafe, prefetchGcpCredentialsIfSafe, validateForceLoginOrg } from './utils/auth.js';
import { checkHasTrustDialogAccepted, getGlobalConfig, getRemoteControlAtStartup, isAutoUpdaterDisabled, saveGlobalConfig } from './utils/config.js';
import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js';
@@ -103,8 +102,8 @@ import { getActiveAgentsFromList, getAgentDefinitionsWithOverrides, isBuiltInAge
import type { LogOption } from './types/logs.js';
import type { Message as MessageType } from './types/message.js';
import { assertMinVersion } from './utils/autoUpdater.js';
import { CLAUDE_IN_CHROME_SKILL_HINT, CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER } from './utils/claudeInChrome/prompt.js';
import { setupClaudeInChrome, shouldAutoEnableClaudeInChrome, shouldEnableClaudeInChrome } from './utils/claudeInChrome/setup.js';
import { mergeClaudeInChromeStartupConfig, resolveClaudeInChromeStartupMode } from './utils/claudeInChrome/startup.js';
import { getContextWindowForModel } from './utils/context.js';
import { loadConversationForResume } from './utils/conversationRecovery.js';
import { buildDeepLinkBanner } from './utils/deepLink/banner.js';
@@ -121,7 +120,7 @@ import { getModelDeprecationWarning } from './utils/model/deprecation.js';
import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js';
import { ensureModelStringsInitialized } from './utils/model/modelStrings.js';
import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js';
import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js';
import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js';
import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js';
import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js';
import { getManagedPluginNames } from './utils/plugins/managedPlugins.js';
@@ -134,7 +133,6 @@ import { ensureMdmSettingsLoaded } from './utils/settings/mdm/settings.js';
import { eagerLoadSettingsFromArgs } from './utils/settings/flagSettings.js';
import { getInitialSettings, getManagedSettingsKeysForLogging, getSettingsForSource, getSettingsWithErrors } from './utils/settings/settings.js';
import type { ValidationError } from './utils/settings/validation.js';
import { DEFAULT_TASKS_MODE_TASK_LIST_ID, TASK_STATUSES } from './utils/tasks.js';
import { logPluginLoadErrors, logPluginsEnabledForSession } from './utils/telemetry/pluginTelemetry.js';
import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js';
import { validateUuid } from './utils/uuid.js';
@@ -174,7 +172,6 @@ const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') ? require('./utils/
import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.js';
import { migrateBypassPermissionsAcceptedToSettings } from './migrations/migrateBypassPermissionsAcceptedToSettings.js';
import { migrateEnableAllProjectMcpServersToSettings } from './migrations/migrateEnableAllProjectMcpServersToSettings.js';
import { migrateFennecToOpus } from './migrations/migrateFennecToOpus.js';
import { migrateLegacyOpusToCurrent } from './migrations/migrateLegacyOpusToCurrent.js';
import { migrateOpusToOpus1m } from './migrations/migrateOpusToOpus1m.js';
import { migrateReplBridgeEnabledToRemoteControlAtStartup } from './migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js';
@@ -204,7 +201,6 @@ import { checkOutTeleportedSessionBranch, processMessagesForTeleportResume, tele
import { shouldEnableThinkingByDefault, type ThinkingConfig } from './utils/thinking.js';
import { initUser, resetUserCache } from './utils/user.js';
import { getTmuxInstallInstructions, isTmuxAvailable, parsePRReference } from './utils/worktree.js';
import { IS_ANT_EMPLOYEE, isAntEmployee } from './utils/buildConfig.js';
// eslint-disable-next-line custom-rules/no-top-level-side-effects
profileCheckpoint('main_tsx_imports_loaded');
@@ -265,7 +261,7 @@ function isBeingDebugged() {
}
// Exit if we detect node debugging or inspection
if (!isAntEmployee() && isBeingDebugged()) {
if (isBeingDebugged()) {
// Use process.exit directly here since we're in the top-level code before imports
// and gracefulShutdown is not yet available
// eslint-disable-next-line custom-rules/no-top-level-side-effects
@@ -340,9 +336,6 @@ function runMigrations(): void {
if (feature('TRANSCRIPT_CLASSIFIER')) {
resetAutoModeOptInForDefaultOffer();
}
if (isAntEmployee()) {
migrateFennecToOpus();
}
saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION ? prev : {
...prev,
migrationVersion: CURRENT_MIGRATION_VERSION
@@ -458,10 +451,6 @@ export function startDeferredPrefetches(): void {
void skillChangeDetector.initialize();
}
// Event loop stall detector — logs when the main thread is blocked >500ms
if (IS_ANT_EMPLOYEE) {
void import('./utils/eventLoopStallDetector.js').then(m => m.startEventLoopStallDetector());
}
}
/**
* Parse and load settings flags early, before init()
@@ -1095,15 +1084,6 @@ async function run(): Promise<CommanderCommand> {
// Extract disable slash commands flag
const disableSlashCommands = options.disableSlashCommands || false;
// Extract tasks mode options (internal-only)
const tasksOption = isAntEmployee() && (options as {
tasks?: boolean | string;
}).tasks;
const taskListId = tasksOption ? typeof tasksOption === 'string' ? tasksOption : DEFAULT_TASKS_MODE_TASK_LIST_ID : undefined;
if (isAntEmployee() && taskListId) {
process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId;
}
// Extract worktree option
// worktree can be true (flag without value) or a string (custom name or PR reference)
const worktreeOption = isWorktreeModeEnabled() ? (options as {
@@ -1485,33 +1465,34 @@ async function run(): Promise<CommanderCommand> {
}
}
// Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant)
// Extract Claude in Chrome option and enforce claude.ai subscriber access.
const chromeOpts = options as {
chrome?: boolean;
};
// Store the explicit CLI flag so teammates can inherit it
setChromeFlagOverride(chromeOpts.chrome);
const enableClaudeInChrome = shouldEnableClaudeInChrome(chromeOpts.chrome) && (isAntEmployee() || isClaudeAISubscriber());
const autoEnableClaudeInChrome = !enableClaudeInChrome && shouldAutoEnableClaudeInChrome();
if (enableClaudeInChrome) {
const claudeInChromeStartupMode = resolveClaudeInChromeStartupMode({
explicitEnabled: shouldEnableClaudeInChrome(chromeOpts.chrome),
autoEnabled: shouldAutoEnableClaudeInChrome(),
hasClaudeInChromeAccess: isClaudeAISubscriber()
});
const enableClaudeInChrome = claudeInChromeStartupMode === 'explicit';
if (claudeInChromeStartupMode === 'explicit') {
const platform = getPlatform();
try {
logEvent('tengu_claude_in_chrome_setup', {
platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
const {
mcpConfig: chromeMcpConfig,
allowedTools: chromeMcpTools,
systemPrompt: chromeSystemPrompt
} = setupClaudeInChrome();
dynamicMcpConfig = {
...dynamicMcpConfig,
...chromeMcpConfig
};
allowedTools.push(...chromeMcpTools);
if (chromeSystemPrompt) {
appendSystemPrompt = appendSystemPrompt ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` : chromeSystemPrompt;
}
const startupConfig = mergeClaudeInChromeStartupConfig({
mode: claudeInChromeStartupMode,
setupResult: setupClaudeInChrome(),
dynamicMcpConfig,
appendSystemPrompt,
hasWebBrowserTool: feature('WEB_BROWSER_TOOL') && typeof Bun !== 'undefined' && 'WebView' in Bun
});
dynamicMcpConfig = startupConfig.dynamicMcpConfig;
allowedTools.push(...startupConfig.allowedTools);
appendSystemPrompt = startupConfig.appendSystemPrompt;
} catch (error) {
logEvent('tengu_claude_in_chrome_setup_failed', {
platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
@@ -1522,17 +1503,17 @@ async function run(): Promise<CommanderCommand> {
console.error(`Error: Failed to run with Claude in Chrome.`);
process.exit(1);
}
} else if (autoEnableClaudeInChrome) {
} else if (claudeInChromeStartupMode === 'auto') {
try {
const {
mcpConfig: chromeMcpConfig
} = setupClaudeInChrome();
dynamicMcpConfig = {
...dynamicMcpConfig,
...chromeMcpConfig
};
const hint = feature('WEB_BROWSER_TOOL') && typeof Bun !== 'undefined' && 'WebView' in Bun ? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER : CLAUDE_IN_CHROME_SKILL_HINT;
appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${hint}` : hint;
const startupConfig = mergeClaudeInChromeStartupConfig({
mode: claudeInChromeStartupMode,
setupResult: setupClaudeInChrome(),
dynamicMcpConfig,
appendSystemPrompt,
hasWebBrowserTool: feature('WEB_BROWSER_TOOL') && typeof Bun !== 'undefined' && 'WebView' in Bun
});
dynamicMcpConfig = startupConfig.dynamicMcpConfig;
appendSystemPrompt = startupConfig.appendSystemPrompt;
} catch (error) {
// Silently skip any errors for the auto-enable
logForDebugging(`[Claude in Chrome] Error (auto-enable): ${error}`);
@@ -1722,13 +1703,6 @@ async function run(): Promise<CommanderCommand> {
overlyBroadBashPermissions
} = initResult;
// Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*))
if (isAntEmployee() && overlyBroadBashPermissions.length > 0) {
for (const permission of overlyBroadBashPermissions) {
logForDebugging(`Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`);
}
toolPermissionContext = removeDangerousPermissions(toolPermissionContext, overlyBroadBashPermissions);
}
if (feature('TRANSCRIPT_CLASSIFIER') && dangerousPermissions.length > 0) {
toolPermissionContext = stripDangerousPermissionsForAutoMode(toolPermissionContext);
}
@@ -1961,22 +1935,6 @@ async function run(): Promise<CommanderCommand> {
cacheSessionTitle(sessionNameArg);
}
// Ant model aliases (capybara-fast etc.) resolve via the
// tengu_ant_model_override GrowthBook flag. _CACHED_MAY_BE_STALE reads
// disk synchronously; disk is populated by a fire-and-forget write. On a
// cold cache, parseUserSpecifiedModel returns the unresolved alias, the
// API 404s, and -p exits before the async write lands — crashloop on
// fresh pods. Awaiting init here populates the in-memory payload map that
// _CACHED_MAY_BE_STALE now checks first. Gated so the warm path stays
// non-blocking:
// - explicit model via --model or ANTHROPIC_MODEL (both feed alias resolution)
// - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk)
// - flag absent from disk (== null also catches pre-#22279 poisoned null)
const explicitModel = options.model || process.env.ANTHROPIC_MODEL;
if (isAntEmployee() && explicitModel && explicitModel !== 'default' && !hasGrowthBookEnvOverride('tengu_ant_model_override') && getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null) {
await initializeGrowthBook();
}
// Special case the default model with the null keyword
// NOTE: Model resolution happens after setup() to ensure trust is established before AWS auth
const userSpecifiedModel = options.model === 'default' ? getDefaultMainLoopModel() : options.model;
@@ -2121,9 +2079,6 @@ async function run(): Promise<CommanderCommand> {
// Log agent memory loaded event for tmux teammates
if (customAgent.memory) {
logEvent('tengu_agent_memory_loaded', {
...(isAntEmployee() && {
agent_type: customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
scope: customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source: 'teammate' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
@@ -2184,10 +2139,6 @@ async function run(): Promise<CommanderCommand> {
const ctx = getRenderContext(false);
getFpsMetrics = ctx.getFpsMetrics;
stats = ctx.stats;
// Install asciicast recorder before Ink mounts (internal-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1)
if (isAntEmployee()) {
installAsciicastRecorder();
}
const {
createRoot
} = await import('./ink.js');
@@ -2776,15 +2727,12 @@ async function run(): Promise<CommanderCommand> {
// In headless mode, start deferred prefetches immediately (no user typing delay)
// --bare / SIMPLE: startDeferredPrefetches early-returns internally.
// backgroundHousekeeping (initExtractMemories, pruneShellSnapshots,
// cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping
// that scripted calls don't need — the next interactive session reconciles.
// cleanupOldMessageFiles) is bookkeeping that scripted calls don't need —
// the next interactive session reconciles.
if (!isBareMode()) {
startDeferredPrefetches();
void import('./utils/backgroundHousekeeping.js').then(m => m.startBackgroundHousekeeping());
startMemoryMonitorIfNeeded();
if (IS_ANT_EMPLOYEE) {
void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor());
}
}
logSessionTelemetry();
profileCheckpoint('before_print_import');
@@ -3024,21 +2972,6 @@ async function run(): Promise<CommanderCommand> {
startMemoryMonitorIfNeeded();
}
// Set up per-turn session environment data uploader (internal-only build).
// Default-enabled for all ant users when working in an Anthropic-owned
// repo. Captures git/filesystem state (NOT transcripts) at each turn so
// environments can be recreated at any user message index. Gating:
// - Build-time: this import is stubbed in external builds.
// - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth.
// - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this).
// Import is dynamic + async to avoid adding startup latency.
const sessionUploaderPromise = IS_ANT_EMPLOYEE ? import('./utils/sessionDataUploader.js') : null;
// Defer session uploader resolution to the onTurnComplete callback to avoid
// adding a new top-level await in main.tsx (performance-critical path).
// The per-turn auth logic in sessionDataUploader.ts handles unauthenticated
// state gracefully (re-checks each turn, so auth recovery mid-session works).
const uploaderReady = sessionUploaderPromise ? sessionUploaderPromise.then(mod => mod.createSessionTurnUploader()).catch(() => null) : null;
const sessionConfig = {
debug: debug || debugToStderr,
commands: [...commands, ...mcpCommands],
@@ -3054,13 +2987,7 @@ async function run(): Promise<CommanderCommand> {
strictMcpConfig,
systemPrompt,
appendSystemPrompt,
taskListId,
thinkingConfig,
...(uploaderReady && {
onTurnComplete: (messages: MessageType[]) => {
void uploaderReady.then(uploader => uploader?.(messages));
}
})
thinkingConfig
};
// Shared context for processResumedConversation calls
@@ -3552,92 +3479,6 @@ async function run(): Promise<CommanderCommand> {
}
}
}
if (IS_ANT_EMPLOYEE) {
if (options.resume && typeof options.resume === 'string' && !maybeSessionId) {
// Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036)
const {
parseCcshareId,
loadCcshare
} = await import('./utils/ccshareResume.js');
const ccshareId = parseCcshareId(options.resume);
if (ccshareId) {
try {
const resumeStart = performance.now();
const logOption = await loadCcshare(ccshareId);
const result = await loadConversationForResume(logOption, undefined);
if (result) {
processedResume = await processResumedConversation(result, {
forkSession: true,
transcriptPath: result.fullPath
}, resumeContext);
if (processedResume.restoredAgentDef) {
mainThreadAgentDefinition = processedResume.restoredAgentDef;
}
logEvent('tengu_session_resumed', {
entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: true,
resume_duration_ms: Math.round(performance.now() - resumeStart)
});
} else {
logEvent('tengu_session_resumed', {
entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: false
});
}
} catch (error) {
logEvent('tengu_session_resumed', {
entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: false
});
logError(error);
await exitWithError(root, `Unable to resume from ccshare: ${errorMessage(error)}`, () => gracefulShutdown(1));
}
} else {
const resolvedPath = resolve(options.resume);
try {
const resumeStart = performance.now();
let logOption;
try {
// Attempt to load as a transcript file; ENOENT falls through to session-ID handling
logOption = await loadTranscriptFromFile(resolvedPath);
} catch (error) {
if (!isENOENT(error)) throw error;
// ENOENT: not a file path — fall through to session-ID handling
}
if (logOption) {
const result = await loadConversationForResume(logOption, undefined /* sourceFile */);
if (result) {
processedResume = await processResumedConversation(result, {
forkSession: !!options.forkSession,
transcriptPath: result.fullPath
}, resumeContext);
if (processedResume.restoredAgentDef) {
mainThreadAgentDefinition = processedResume.restoredAgentDef;
}
logEvent('tengu_session_resumed', {
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: true,
resume_duration_ms: Math.round(performance.now() - resumeStart)
});
} else {
logEvent('tengu_session_resumed', {
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: false
});
}
}
} catch (error) {
logEvent('tengu_session_resumed', {
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: false
});
logError(error);
await exitWithError(root, errorMessage(error), () => gracefulShutdown(1));
}
}
}
}
// If not loaded as a file, try as session ID
if (maybeSessionId) {
// Resume specific session by ID
@@ -3783,19 +3624,6 @@ async function run(): Promise<CommanderCommand> {
if (canUserConfigureAdvisor()) {
program.addOption(new Option('--advisor <model>', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp());
}
if (isAntEmployee()) {
program.addOption(new Option('--delegate-permissions', '[internal-only] Alias for --permission-mode auto.').implies({
permissionMode: 'auto'
}));
program.addOption(new Option('--dangerously-skip-permissions-with-classifiers', '[internal-only] Deprecated alias for --permission-mode auto.').hideHelp().implies({
permissionMode: 'auto'
}));
program.addOption(new Option('--afk', '[internal-only] Deprecated alias for --permission-mode auto.').hideHelp().implies({
permissionMode: 'auto'
}));
program.addOption(new Option('--tasks [id]', '[internal-only] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").').argParser(String).hideHelp());
program.option('--agent-teams', '[internal-only] Force Claude to use multi-agent mode for solving problems', () => true);
}
if (feature('TRANSCRIPT_CLASSIFIER')) {
program.addOption(new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp());
}
@@ -4355,31 +4183,6 @@ async function run(): Promise<CommanderCommand> {
await update();
});
// claude up — run the project's CLAUDE.md "# claude up" setup instructions.
if (IS_ANT_EMPLOYEE) {
program.command('up').description('[internal-only] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md').action(async () => {
const {
up
} = await import('src/cli/up.js');
await up();
});
}
// claude rollback (internal-only)
// Rolls back to previous releases
if (IS_ANT_EMPLOYEE) {
program.command('rollback [target]').description('[internal-only] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version').option('-l, --list', 'List recent published versions with ages').option('--dry-run', 'Show what would be installed without installing').option('--safe', 'Roll back to the server-pinned safe version (set by oncall during incidents)').action(async (target?: string, options?: {
list?: boolean;
dryRun?: boolean;
safe?: boolean;
}) => {
const {
rollback
} = await import('src/cli/rollback.js');
await rollback(target, options);
});
}
// claude install
program.command('install [target]').description('Install OpenClaude native build. Use [target] to specify version (stable, latest, or specific version)').option('--force', 'Force installation even if already installed').action(async (target: string | undefined, options: {
force?: boolean;
@@ -4390,105 +4193,6 @@ async function run(): Promise<CommanderCommand> {
await installHandler(target, options);
});
// internal-only commands
if (IS_ANT_EMPLOYEE) {
const validateLogId = (value: string) => {
const maybeSessionId = validateUuid(value);
if (maybeSessionId) return maybeSessionId;
return Number(value);
};
// claude log
program.command('log').description('[internal-only] Manage conversation logs.').argument('[number|sessionId]', 'A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log', validateLogId).action(async (logId: string | number | undefined) => {
const {
logHandler
} = await import('./cli/handlers/ant.js');
await logHandler(logId);
});
// claude error
program.command('error').description('[internal-only] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.').argument('[number]', 'A number (0, 1, 2, etc.) to display a specific log', parseInt).action(async (number: number | undefined) => {
const {
errorHandler
} = await import('./cli/handlers/ant.js');
await errorHandler(number);
});
// claude export
program.command('export').description('[internal-only] Export a conversation to a text file.').usage('<source> <outputFile>').argument('<source>', 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file').argument('<outputFile>', 'Output file path for the exported text').addHelpText('after', `
Examples:
$ claude export 0 conversation.txt Export conversation at log index 0
$ claude export <uuid> conversation.txt Export conversation by session ID
$ claude export input.json output.txt Render JSON log file to text
$ claude export <uuid>.jsonl output.txt Render JSONL session file to text`).action(async (source: string, outputFile: string) => {
const {
exportHandler
} = await import('./cli/handlers/ant.js');
await exportHandler(source, outputFile);
});
if (IS_ANT_EMPLOYEE) {
const taskCmd = program.command('task').description('[internal-only] Manage task list tasks');
taskCmd.command('create <subject>').description('Create a new task').option('-d, --description <text>', 'Task description').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').action(async (subject: string, opts: {
description?: string;
list?: string;
}) => {
const {
taskCreateHandler
} = await import('./cli/handlers/ant.js');
await taskCreateHandler(subject, opts);
});
taskCmd.command('list').description('List all tasks').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').option('--pending', 'Show only pending tasks').option('--json', 'Output as JSON').action(async (opts: {
list?: string;
pending?: boolean;
json?: boolean;
}) => {
const {
taskListHandler
} = await import('./cli/handlers/ant.js');
await taskListHandler(opts);
});
taskCmd.command('get <id>').description('Get details of a task').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').action(async (id: string, opts: {
list?: string;
}) => {
const {
taskGetHandler
} = await import('./cli/handlers/ant.js');
await taskGetHandler(id, opts);
});
taskCmd.command('update <id>').description('Update a task').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').option('-s, --status <status>', `Set status (${TASK_STATUSES.join(', ')})`).option('--subject <text>', 'Update subject').option('-d, --description <text>', 'Update description').option('--owner <agentId>', 'Set owner').option('--clear-owner', 'Clear owner').action(async (id: string, opts: {
list?: string;
status?: string;
subject?: string;
description?: string;
owner?: string;
clearOwner?: boolean;
}) => {
const {
taskUpdateHandler
} = await import('./cli/handlers/ant.js');
await taskUpdateHandler(id, opts);
});
taskCmd.command('dir').description('Show the tasks directory path').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').action(async (opts: {
list?: string;
}) => {
const {
taskDirHandler
} = await import('./cli/handlers/ant.js');
await taskDirHandler(opts);
});
}
// claude completion <shell>
program.command('completion <shell>', {
hidden: true
}).description('Generate shell completion script (bash, zsh, or fish)').option('--output <file>', 'Write completion script directly to a file instead of stdout').action(async (shell: string, opts: {
output?: string;
}) => {
const {
completionHandler
} = await import('./cli/handlers/ant.js');
await completionHandler(shell, opts, program);
});
}
profileCheckpoint('run_before_parse');
await program.parseAsync(process.argv);
profileCheckpoint('run_after_parse');
@@ -4583,15 +4287,7 @@ async function logTenguInit({
...(assistantActivationPath && {
assistantActivationPath: assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? 'latest') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(isAntEmployee() ? (() => {
const cwd = getCwd();
const gitRoot = findGitRoot(cwd);
const rp = gitRoot ? relative(gitRoot, cwd) || '.' : undefined;
return rp ? {
relativeProjectPath: rp as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
} : {};
})() : {})
autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? 'latest') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
} catch (error) {
logError(error);
+16 -246
View File
@@ -30,7 +30,7 @@ import { useTerminalNotification } from '../ink/useTerminalNotification.js';
import { hasCursorUpViewportYankBug } from '../ink/terminal.js';
import { createFileStateCacheWithSizeLimit, mergeFileStateCaches, READ_FILE_STATE_CACHE_SIZE } from '../utils/fileStateCache.js';
import { registerPrunableCache } from '../utils/memoryPressure.js';
import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, getTurnHookDurationMs, getTurnHookCount, resetTurnHookDuration, getTurnToolDurationMs, getTurnToolCount, resetTurnToolDuration, getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration, setMainLoopModelOverride, setMainThreadAgentType } from '../bootstrap/state.js';
import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, resetTurnHookDuration, resetTurnToolDuration, resetTurnClassifierDuration, setMainLoopModelOverride, setMainThreadAgentType } from '../bootstrap/state.js';
import { asSessionId, asAgentId } from '../types/ids.js';
import { logForDebugging } from '../utils/debug.js';
import { QueryGuard } from '../utils/QueryGuard.js';
@@ -65,9 +65,6 @@ import type { DirectConnectConfig } from '../server/directConnectManager.js';
import { useSSHSession } from '../hooks/useSSHSession.js';
import { useAssistantHistory } from '../hooks/useAssistantHistory.js';
import type { SSHSession } from '../ssh/createSSHSession.js';
import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js';
import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js';
import { useMoreRight } from '../moreright/useMoreRight.js';
import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode } from '../components/Spinner.js';
import { getSystemPrompt } from '../constants/prompts.js';
import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js';
@@ -103,16 +100,6 @@ const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useV
resetAnchor: () => { }
});
const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null;
// Frustration detection is internal-only (dogfooding). Conditional require so external
// builds eliminate the module entirely (including its two O(n) useMemos that run
// on every messages change, plus the GrowthBook fetch).
const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = IS_ANT_EMPLOYEE ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({
state: 'closed',
handleTranscriptSelect: () => { }
});
// Ant-only org warning. Conditional require so the org UUID list is
// eliminated from external builds (one UUID is on excluded-strings).
const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = IS_ANT_EMPLOYEE ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => { };
// Dead code elimination: conditional import for coordinator mode
const getCoordinatorUserContext: (mcpClients: ReadonlyArray<{
name: string;
@@ -131,11 +118,11 @@ import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js';
import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js';
import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js';
import type { AutoUpdaterResult } from '../utils/autoUpdater.js';
import { getGlobalConfig, saveGlobalConfig, getGlobalConfigWriteCount } from '../utils/config.js';
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js';
import { hasConsoleBillingAccess } from '../utils/billing.js';
import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/index.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js';
import { textForResubmit, handleMessageFromStream, type StreamingToolUse, type StreamingThinking, isCompactBoundaryMessage, getMessagesAfterCompactBoundary, getContentText, createUserMessage, createAssistantMessage, createTurnDurationMessage, createAgentsKilledMessage, createApiMetricsMessage, createSystemMessage, createCommandInputMessage, formatCommandInputTags } from '../utils/messages.js';
import { textForResubmit, handleMessageFromStream, type StreamingToolUse, type StreamingThinking, isCompactBoundaryMessage, getMessagesAfterCompactBoundary, getContentText, createUserMessage, createAssistantMessage, createTurnDurationMessage, createAgentsKilledMessage, createSystemMessage, createCommandInputMessage, formatCommandInputTags } from '../utils/messages.js';
import { getCurrentTurnCacheMetrics, resetCurrentTurn } from '../services/api/cacheStatsTracker.js';
import { formatCacheMetricsCompact, formatCacheMetricsFull } from '../services/api/cacheMetrics.js';
import { generateSessionTitle } from '../utils/sessionTitle.js';
@@ -204,11 +191,9 @@ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../
const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => { };
const PROACTIVE_FALSE = () => false;
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
const useScheduledTasks = require('../hooks/useScheduledTasks.js').useScheduledTasks;
/* eslint-enable @typescript-eslint/no-require-imports */
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
import type { SandboxAskCallback, NetworkHostPattern } from '../utils/sandbox/sandbox-adapter.js';
import { type IDEExtensionInstallationStatus, closeOpenDiffs, getConnectedIdeClient, type IdeType } from '../utils/ide.js';
import { useIDEIntegration } from '../hooks/useIDEIntegration.js';
@@ -227,11 +212,6 @@ import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCall
import type { EffortValue } from '../utils/effort.js';
import { RemoteCallout } from '../components/RemoteCallout.js';
import { getAPIProvider } from '../utils/model/providers.js';
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
const AntModelSwitchCallout = IS_ANT_EMPLOYEE ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null;
const shouldShowAntModelSwitch = IS_ANT_EMPLOYEE ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false;
const UndercoverAutoCallout = IS_ANT_EMPLOYEE ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null;
/* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
import { activityManager } from '../utils/activityManager.js';
import { createAbortController } from '../utils/abortController.js';
import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js';
@@ -280,7 +260,6 @@ import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateSh
import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js';
import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason } from '../utils/autoRunIssue.js';
import type { HookProgress } from '../types/hooks.js';
import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js';
/* eslint-disable @typescript-eslint/no-require-imports */
const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js') : null;
/* eslint-enable @typescript-eslint/no-require-imports */
@@ -289,7 +268,6 @@ import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js';
import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js';
import { isBuddyEnabled } from '../buddy/feature.js';
import { fireCompanionObserver } from '../buddy/observer.js';
import { DevBar } from '../components/DevBar.js';
// Session manager removed - using AppState now
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js';
import { REMOTE_SAFE_COMMANDS } from '../commands.js';
@@ -302,7 +280,6 @@ import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type M
import { setClipboard } from '../ink/termio/osc.js';
import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js';
import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js';
import { IS_ANT_EMPLOYEE, isAntEmployee } from '../utils/buildConfig.js';
// Stable empty array for hooks that accept MCPServerConnection[] — avoids
// creating a new [] literal on every render in remote mode, which would
@@ -577,8 +554,6 @@ export type Props = {
hasExplicitModelOverride?: boolean;
// When true, disables all slash commands
disableSlashCommands?: boolean;
// Task list id: when set, enables tasks mode that watches a task list and auto-processes tasks.
taskListId?: string;
// Remote session config for --remote mode (uses CCR as execution engine)
remoteSessionConfig?: RemoteSessionConfig;
// Direct connect config for `claude connect` mode (connects to a claude server)
@@ -614,7 +589,6 @@ export function REPL({
baseMainLoopModel = null,
hasExplicitModelOverride = false,
disableSlashCommands = false,
taskListId,
remoteSessionConfig,
directConnectConfig,
sshSession,
@@ -626,7 +600,6 @@ export function REPL({
// Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+
// includes, and these were on the render path (hot during PageUp spam).
const titleDisabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), []);
const moreRightEnabled = useMemo(() => isAntEmployee() && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []);
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
const disableMessageActions = feature('MESSAGE_ACTIONS') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
@@ -802,13 +775,6 @@ export function REPL({
const [ideToInstallExtension, setIDEToInstallExtension] = useState<IdeType | null>(null);
const [ideInstallationStatus, setIDEInstallationStatus] = useState<IDEExtensionInstallationStatus | null>(null);
const [showIdeOnboarding, setShowIdeOnboarding] = useState(false);
// Dead code elimination: model switch callout state (internal-only)
const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => {
if (isAntEmployee()) {
return shouldShowAntModelSwitch();
}
return false;
});
const [showEffortCallout, setShowEffortCallout] = useState(() => shouldShowEffortCallout(mainLoopModel));
const showRemoteCallout = useAppState(s => s.showRemoteCallout);
const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() => shouldShowDesktopUpsellStartup());
@@ -831,7 +797,6 @@ export function REPL({
useFastModeNotification();
useDeprecationWarningNotification(mainLoopModel);
useNpmDeprecationNotification();
useAntOrgWarningNotification();
useInstallMessages();
useChromeExtensionNotification();
useOfficialMarketplaceNotification();
@@ -1085,25 +1050,6 @@ export function REPL({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [showUndercoverCallout, setShowUndercoverCallout] = useState(false);
useEffect(() => {
if (isAntEmployee()) {
void (async () => {
// Wait for repo classification to settle (memoized, no-op if primed).
const {
isInternalModelRepo
} = await import('../utils/commitAttribution.js');
await isInternalModelRepo();
const {
shouldShowUndercoverAutoNotice
} = await import('../utils/undercover.js');
if (shouldShowUndercoverAutoNotice()) {
setShowUndercoverCallout(true);
}
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [toolJSX, setToolJSXInternal] = useState<{
jsx: React.ReactNode | null;
shouldHidePromptInput: boolean;
@@ -1528,34 +1474,8 @@ export function REPL({
// Ref instead of state to avoid triggering React re-renders on every
// streaming text_delta. The spinner reads this via its animation timer.
const responseLengthRef = useRef(0);
// API performance metrics ref for internal-only spinner display (TTFT/OTPS).
// Accumulates metrics from all API requests in a turn for P50 aggregation.
const apiMetricsRef = useRef<Array<{
ttftMs: number;
firstTokenTime: number;
lastTokenTime: number;
responseLengthBaseline: number;
// Tracks responseLengthRef at the time of the last content addition.
// Updated by both streaming deltas and subagent message content.
// lastTokenTime is also updated at the same time, so the OTPS
// denominator correctly includes subagent processing time.
endResponseLength: number;
}>>([]);
const setResponseLength = useCallback((f: (prev: number) => number) => {
const prev = responseLengthRef.current;
responseLengthRef.current = f(prev);
// When content is added (not a compaction reset), update the latest
// metrics entry so OTPS reflects all content generation activity.
// Updating lastTokenTime here ensures the denominator includes both
// streaming time AND subagent execution time, preventing inflation.
if (responseLengthRef.current > prev) {
const entries = apiMetricsRef.current;
if (entries.length > 0) {
const lastEntry = entries.at(-1)!;
lastEntry.lastTokenTime = Date.now();
lastEntry.endResponseLength = responseLengthRef.current;
}
}
responseLengthRef.current = f(responseLengthRef.current);
}, []);
// Streaming text display: set state directly per delta (Ink's 16ms render
@@ -1675,7 +1595,6 @@ export function REPL({
setIsExternalLoading(false);
setUserInputOnProcessing(undefined);
responseLengthRef.current = 0;
apiMetricsRef.current = [];
setStreamingText(null);
setStreamingToolUses([]);
setSpinnerMessage(null);
@@ -1760,17 +1679,9 @@ export function REPL({
const inProgressToolUses = lastAssistant.message.content.filter(b => b.type === 'tool_use' && inProgressToolUseIDs.has(b.id));
return inProgressToolUses.length > 0 && inProgressToolUses.every(b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME);
}, [messages, inProgressToolUseIDs]);
const {
onBeforeQuery: mrOnBeforeQuery,
onTurnComplete: mrOnTurnComplete,
render: mrRender
} = useMoreRight({
enabled: moreRightEnabled,
setMessages,
inputValue,
setInputValue,
setToolJSX
});
const mrOnBeforeQuery = useCallback(async (_input: string, _allMessages: MessageType[], _newMessageCount: number) => true, []);
const mrOnTurnComplete = useCallback(async (_allMessages: MessageType[], _aborted: boolean) => { }, []);
const mrRender = useCallback(() => null, []);
const showSpinner = (!toolJSX || toolJSX.showSpinner === true) && toolUseConfirmQueue.length === 0 && promptQueue.length === 0 && (
// Show spinner during input processing, API call, while teammates are running,
// or while pending task notifications are queued (prevents spinner bounce between consecutive notifications)
@@ -1790,7 +1701,6 @@ export function REPL({
// This is used to prevent the survey from opening while prompts are active
const hasActivePrompt = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || sandboxPermissionRequestQueue.length > 0 || elicitation.queue.length > 0 || workerSandboxPermissions.queue.length > 0;
const feedbackSurveyOriginal = useFeedbackSurvey(messages, isLoading, submitCount, 'session', hasActivePrompt);
const skillImprovementSurvey = useSkillImprovementSurvey(setMessages);
const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount);
// Wrap feedback survey handler to trigger auto-run /issue
@@ -1819,8 +1729,10 @@ export function REPL({
enabled: !isRemoteSession
});
// Frustration detection: show transcript sharing prompt after detecting frustrated messages
const frustrationDetection = useFrustrationDetection(messages, isLoading, hasActivePrompt, feedbackSurvey.state !== 'closed' || postCompactSurvey.state !== 'closed' || memorySurvey.state !== 'closed');
const frustrationDetection = useMemo(() => ({
state: 'closed' as const,
handleTranscriptSelect: () => { }
}), []);
// Initialize IDE integration
useIDEIntegration({
@@ -2129,7 +2041,7 @@ export function REPL({
// Permission and interactive dialogs can show even when toolJSX is set,
// as long as shouldContinueAnimation is true. This prevents deadlocks when
// agents set background hints while waiting for user interaction.
function getFocusedInputDialog(): 'message-selector' | 'sandbox-permission' | 'tool-permission' | 'prompt' | 'worker-sandbox-permission' | 'elicitation' | 'cost' | 'idle-return' | 'init-onboarding' | 'ide-onboarding' | 'model-switch' | 'undercover-callout' | 'effort-callout' | 'remote-callout' | 'lsp-recommendation' | 'plugin-hint' | 'desktop-upsell' | 'ultraplan-choice' | 'ultraplan-launch' | undefined {
function getFocusedInputDialog(): 'message-selector' | 'sandbox-permission' | 'tool-permission' | 'prompt' | 'worker-sandbox-permission' | 'elicitation' | 'cost' | 'idle-return' | 'init-onboarding' | 'ide-onboarding' | 'effort-callout' | 'remote-callout' | 'lsp-recommendation' | 'plugin-hint' | 'desktop-upsell' | 'ultraplan-choice' | 'ultraplan-launch' | undefined {
// Exit states always take precedence
if (isExiting || exitFlow) return undefined;
@@ -2157,12 +2069,6 @@ export function REPL({
// Onboarding dialogs (special conditions)
if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding';
// Model switch callout (internal-only, eliminated from external builds)
if (isAntEmployee() && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
// Undercover auto-enable explainer (internal-only, eliminated from external builds)
if (isAntEmployee() && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
// Effort callout (shown once for Opus 4.6 users when effort is enabled)
if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout';
@@ -2597,17 +2503,6 @@ export function REPL({
dynamicSkillDirTriggers: new Set<string>(),
discoveredSkillNames: discoveredSkillNamesRef.current,
setResponseLength,
pushApiMetricsEntry: isAntEmployee() ? (ttftMs: number) => {
const now = Date.now();
const baseline = responseLengthRef.current;
apiMetricsRef.current.push({
ttftMs,
firstTokenTime: now,
lastTokenTime: now,
responseLengthBaseline: baseline,
endResponseLength: baseline
});
} : undefined,
setStreamMode,
onCompactProgress: event => {
switch (event.type) {
@@ -2786,24 +2681,12 @@ export function REPL({
}
}
}, newContent => {
// setResponseLength handles updating both responseLengthRef (for
// spinner animation) and apiMetricsRef (endResponseLength/lastTokenTime
// for OTPS). No separate metrics update needed here.
// setResponseLength updates responseLengthRef for spinner animation.
setResponseLength(length => length + newContent.length);
}, setStreamMode, setStreamingToolUses, tombstonedMessage => {
setMessages(oldMessages => oldMessages.filter(m => m !== tombstonedMessage));
void removeTranscriptMessage(tombstonedMessage.uuid);
}, setStreamingThinking, metrics => {
const now = Date.now();
const baseline = responseLengthRef.current;
apiMetricsRef.current.push({
...metrics,
firstTokenTime: now,
lastTokenTime: now,
responseLengthBaseline: baseline,
endResponseLength: baseline
});
}, onStreamingText);
}, setStreamingThinking, undefined, onStreamingText);
}, [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText]);
const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, effort?: EffortValue) => {
// Prepare IDE integration for new prompt. Read mcpClients fresh from
@@ -2967,41 +2850,6 @@ export function REPL({
}
queryCheckpoint('query_end');
// Capture internal-only API metrics before resetLoadingState clears the ref.
// For multi-request turns (tool use loops), compute P50 across all requests.
if (isAntEmployee() && apiMetricsRef.current.length > 0) {
const entries = apiMetricsRef.current;
const ttfts = entries.map(e => e.ttftMs);
// Compute per-request OTPS using only active streaming time and
// streaming-only content. endResponseLength tracks content added by
// streaming deltas only, excluding subagent/compaction inflation.
const otpsValues = entries.map(e => {
const delta = Math.round((e.endResponseLength - e.responseLengthBaseline) / 4);
const samplingMs = e.lastTokenTime - e.firstTokenTime;
return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0;
});
const isMultiRequest = entries.length > 1;
const hookMs = getTurnHookDurationMs();
const hookCount = getTurnHookCount();
const toolMs = getTurnToolDurationMs();
const toolCount = getTurnToolCount();
const classifierMs = getTurnClassifierDurationMs();
const classifierCount = getTurnClassifierCount();
const turnMs = Date.now() - loadingStartTimeRef.current;
setMessages(prev => [...prev, createApiMetricsMessage({
ttftMs: isMultiRequest ? median(ttfts) : ttfts[0]!,
otps: isMultiRequest ? median(otpsValues) : otpsValues[0]!,
isP50: isMultiRequest,
hookDurationMs: hookMs > 0 ? hookMs : undefined,
hookCount: hookCount > 0 ? hookCount : undefined,
turnDurationMs: turnMs > 0 ? turnMs : undefined,
toolDurationMs: toolMs > 0 ? toolMs : undefined,
toolCount: toolCount > 0 ? toolCount : undefined,
classifierDurationMs: classifierMs > 0 ? classifierMs : undefined,
classifierCount: classifierCount > 0 ? classifierCount : undefined,
configWriteCount: getGlobalConfigWriteCount()
})]);
}
resetLoadingState();
// Log query profiling report if enabled
@@ -3059,7 +2907,6 @@ export function REPL({
const parsedBudget = input ? parseTokenBudget(input) : null;
snapshotOutputTokensForTurn(parsedBudget ?? getCurrentTurnTokenBudget());
}
apiMetricsRef.current = [];
setStreamingToolUses([]);
setStreamingText(null);
@@ -3098,23 +2945,6 @@ export function REPL({
// can stop the spark animation and show post-turn UI.
sendBridgeResultRef.current();
// Auto-hide tungsten panel content at turn end (internal-only), but keep
// tungstenActiveSession set so the pill stays in the footer and the user
// can reopen the panel. Background tmux tasks (e.g. /hunter) run for
// minutes — wiping the session made the pill disappear entirely, forcing
// the user to re-invoke Tmux just to peek. Skip on abort so the panel
// stays open for inspection (matches the turn-duration guard below).
if (isAntEmployee() && !abortController.signal.aborted) {
setAppState(prev => {
if (prev.tungstenActiveSession === undefined) return prev;
if (prev.tungstenPanelAutoHidden === true) return prev;
return {
...prev,
tungstenPanelAutoHidden: true
};
});
}
// Capture budget info before clearing (internal-only)
let budgetInfo: {
tokens: number;
@@ -3259,8 +3089,7 @@ export function REPL({
}
}
// Atomically: clear initial message, set permission mode and rules, and store plan for verification
const shouldStorePlanForVerification = initialMsg.message.planContent && isAntEmployee() && isEnvTruthy(undefined);
// Atomically clear the initial message and apply permission mode/rules.
setAppState(prev => {
// Build and apply permission updates (mode + allowedPrompts rules)
let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdatesToLiveContext(prev.toolPermissionContext, buildPermissionUpdates(initialMsg.mode, initialMsg.allowedPrompts)) : prev.toolPermissionContext;
@@ -3276,14 +3105,7 @@ export function REPL({
return {
...prev,
initialMessage: null,
toolPermissionContext: updatedToolPermissionContext,
...(shouldStorePlanForVerification && {
pendingPlanVerification: {
plan: initialMsg.message.planContent!,
verificationStarted: false,
verificationCompleted: false
}
})
toolPermissionContext: updatedToolPermissionContext
};
});
@@ -4244,38 +4066,6 @@ export function REPL({
// - Workers receive permission responses via mailbox messages
// - Leaders receive permission requests via mailbox messages
if (isAntEmployee()) {
// Tasks mode: watch for tasks and auto-process them
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
useTaskListWatcher({
taskListId,
isLoading,
onSubmitTask: handleIncomingPrompt
});
// Loop mode: auto-tick when enabled (via /job command)
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
useProactive?.({
// Suppress ticks while an initial message is pending — the initial
// message will be processed asynchronously and a premature tick would
// race with it, causing concurrent-query enqueue of expanded skill text.
isLoading: isLoading || initialMessage !== null,
queuedCommandsLength: queuedCommands.length,
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
isInPlanMode: toolPermissionContext.mode === 'plan',
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, {
isMeta: true
}),
onQueueTick: (prompt: string) => enqueue({
mode: 'prompt',
value: prompt,
isMeta: true
})
});
}
// Abort the current operation when a 'now' priority message arrives
// (e.g. from a chat UI client via UDS).
useEffect(() => {
@@ -4353,11 +4143,6 @@ export function REPL({
// Fall back to default behavior
const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop';
if (isAntEmployee()) {
const cmd = currentHooks[completedCount]?.data.command;
const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : '';
return total === 1 ? `running ${hookType} hook${label}` : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`;
}
return total === 1 ? `running ${hookType} hook` : `running stop hooks… ${completedCount}/${total}`;
}, [messages, isLoading]);
@@ -4762,7 +4547,6 @@ export function REPL({
{toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && <Box flexDirection="column" width="100%">
{toolJSX.jsx}
</Box>}
{isAntEmployee() && <TungstenLiveMonitor />}
{feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null}
<Box flexGrow={1} />
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
@@ -4986,17 +4770,6 @@ export function REPL({
});
}} />}
{focusedInputDialog === 'ide-onboarding' && <IdeOnboardingDialog onDone={() => setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />}
{isAntEmployee() && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && <AntModelSwitchCallout onDone={(selection: string, modelAlias?: string) => {
setShowModelSwitchCallout(false);
if (selection === 'switch' && modelAlias) {
setAppState(prev => ({
...prev,
mainLoopModel: modelAlias,
mainLoopModelForSession: null
}));
}
}} />}
{isAntEmployee() && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && <UndercoverAutoCallout onDone={() => setShowUndercoverCallout(false)} />}
{focusedInputDialog === 'effort-callout' && <EffortCallout model={mainLoopModel} onDone={selection => {
setShowEffortCallout(false);
if (selection !== 'dismiss') {
@@ -5078,8 +4851,6 @@ export function REPL({
{postCompactSurvey.state !== 'closed' ? <FeedbackSurvey state={postCompactSurvey.state} lastResponse={postCompactSurvey.lastResponse} handleSelect={postCompactSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} /> : memorySurvey.state !== 'closed' ? <FeedbackSurvey state={memorySurvey.state} lastResponse={memorySurvey.lastResponse} handleSelect={memorySurvey.handleSelect} handleTranscriptSelect={memorySurvey.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} message="How well did Claude use its memory? (optional)" /> : <FeedbackSurvey state={feedbackSurvey.state} lastResponse={feedbackSurvey.lastResponse} handleSelect={feedbackSurvey.handleSelect} handleTranscriptSelect={feedbackSurvey.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />}
{/* Frustration-triggered transcript sharing prompt */}
{frustrationDetection.state !== 'closed' && <FeedbackSurvey state={frustrationDetection.state} lastResponse={null} handleSelect={() => { }} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />}
{/* Skill improvement survey - appears when improvements detected (internal-only) */}
{isAntEmployee() && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
{showIssueFlagBanner && <IssueFlagBanner />}
{ }
<PromptInput debug={debug} ideSelection={ideSelection} isLocalJSXCommandActive={isShowingLocalJSXCommand} getToolUseContext={getToolUseContext} toolPermissionContext={toolPermissionContext} setToolPermissionContext={setToolPermissionContext} apiKeyStatus={apiKeyStatus} commands={renderCommands} agents={agentDefinitions.activeAgents} isLoading={isLoading} onExit={handleExit} verbose={verbose} messages={messages} onAutoUpdaterResult={setAutoUpdaterResult} autoUpdaterResult={autoUpdaterResult} input={inputValue} onInputChange={setInputValue} mode={inputMode} onModeChange={setInputMode} stashedPrompt={stashedPrompt} setStashedPrompt={setStashedPrompt} submitCount={submitCount} onShowMessageSelector={handleShowMessageSelector} onMessageActionsEnter={
@@ -5173,7 +4944,6 @@ export function REPL({
setIsMessageSelectorVisible(false);
setMessageSelectorPreselect(undefined);
}} />}
{isAntEmployee() && <DevBar />}
</Box>
{isBuddyEnabled() && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? <CompanionSprite /> : null}
</Box>} />
+1 -3
View File
@@ -63,7 +63,6 @@ type Props = {
initialSearchQuery?: string;
disableSlashCommands?: boolean;
forkSession?: boolean;
taskListId?: string;
filterByPr?: boolean | number | string;
thinkingConfig: ThinkingConfig;
fallbackModel?: string;
@@ -86,7 +85,6 @@ export function ResumeConversation({
initialSearchQuery,
disableSlashCommands = false,
forkSession,
taskListId,
filterByPr,
thinkingConfig,
fallbackModel,
@@ -305,7 +303,7 @@ export function ResumeConversation({
return <CrossProjectMessage command={crossProjectCommand} />;
}
if (resumeData) {
return <REPL debug={debug} commands={commands} initialTools={initialTools} initialMessages={resumeData.messages} initialFileHistorySnapshots={resumeData.fileHistorySnapshots} initialContentReplacements={resumeData.contentReplacements} initialAgentName={resumeData.agentName} initialAgentColor={resumeData.agentColor} mcpClients={mcpClients} dynamicMcpConfig={dynamicMcpConfig} strictMcpConfig={strictMcpConfig} systemPrompt={systemPrompt} appendSystemPrompt={appendSystemPrompt} mainThreadAgentDefinition={resumeData.mainThreadAgentDefinition} baseMainLoopModel={baseMainLoopModel} hasExplicitModelOverride={hasExplicitModelOverride} autoConnectIdeFlag={autoConnectIdeFlag} disableSlashCommands={disableSlashCommands} taskListId={taskListId} thinkingConfig={thinkingConfig} fallbackModel={fallbackModel} onTurnComplete={onTurnComplete} />;
return <REPL debug={debug} commands={commands} initialTools={initialTools} initialMessages={resumeData.messages} initialFileHistorySnapshots={resumeData.fileHistorySnapshots} initialContentReplacements={resumeData.contentReplacements} initialAgentName={resumeData.agentName} initialAgentColor={resumeData.agentColor} mcpClients={mcpClients} dynamicMcpConfig={dynamicMcpConfig} strictMcpConfig={strictMcpConfig} systemPrompt={systemPrompt} appendSystemPrompt={appendSystemPrompt} mainThreadAgentDefinition={resumeData.mainThreadAgentDefinition} baseMainLoopModel={baseMainLoopModel} hasExplicitModelOverride={hasExplicitModelOverride} autoConnectIdeFlag={autoConnectIdeFlag} disableSlashCommands={disableSlashCommands} thinkingConfig={thinkingConfig} fallbackModel={fallbackModel} onTurnComplete={onTurnComplete} />;
}
if (loading) {
return <Box>
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test'
import { shouldEnableClaudeInChromeSkill } from './claudeInChromeAccess.js'
describe('shouldEnableClaudeInChromeSkill', () => {
test('requires both auto-enable eligibility and subscriber access', () => {
expect(
shouldEnableClaudeInChromeSkill({
autoEnabled: true,
hasClaudeInChromeAccess: true,
}),
).toBe(true)
expect(
shouldEnableClaudeInChromeSkill({
autoEnabled: true,
hasClaudeInChromeAccess: false,
}),
).toBe(false)
expect(
shouldEnableClaudeInChromeSkill({
autoEnabled: false,
hasClaudeInChromeAccess: true,
}),
).toBe(false)
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
import { BROWSER_TOOLS } from '@ant/claude-for-chrome-mcp'
import { BASE_CHROME_PROMPT } from '../../utils/claudeInChrome/prompt.js'
import { shouldAutoEnableClaudeInChrome } from '../../utils/claudeInChrome/setup.js'
import { registerBundledSkill } from '../bundledSkills.js'
import { shouldEnableClaudeInChromeSkill } from './claudeInChromeAccess.js'
const CLAUDE_IN_CHROME_MCP_TOOLS = BROWSER_TOOLS.map(
tool => `mcp__claude-in-chrome__${tool.name}`,
@@ -22,7 +22,7 @@ export function registerClaudeInChromeSkill(): void {
'When the user wants to interact with web pages, automate browser tasks, capture screenshots, read console logs, or perform any browser-based actions. Always invoke BEFORE attempting to use any mcp__claude-in-chrome__* tools.',
allowedTools: CLAUDE_IN_CHROME_MCP_TOOLS,
userInvocable: true,
isEnabled: () => shouldAutoEnableClaudeInChrome(),
isEnabled: () => shouldEnableClaudeInChromeSkill(),
async getPromptForCommand(args) {
let prompt = `${BASE_CHROME_PROMPT}\n${SKILL_ACTIVATION_MESSAGE}`
if (args) {
@@ -0,0 +1,22 @@
import { isClaudeAISubscriber } from '../../utils/auth.js'
export function shouldEnableClaudeInChromeSkill(options?: {
autoEnabled?: boolean
hasClaudeInChromeAccess?: boolean
}): boolean {
const autoEnabled =
options?.autoEnabled ?? defaultShouldAutoEnableClaudeInChrome()
const hasClaudeInChromeAccess =
options?.hasClaudeInChromeAccess ?? isClaudeAISubscriber()
return autoEnabled && hasClaudeInChromeAccess
}
// Keep this lazy to avoid importing setup.ts while startup code is still
// wiring shared Chrome-in-Claude state.
function defaultShouldAutoEnableClaudeInChrome(): boolean {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { shouldAutoEnableClaudeInChrome } = require(
'../../utils/claudeInChrome/setup.js',
) as typeof import('../../utils/claudeInChrome/setup.js')
return shouldAutoEnableClaudeInChrome()
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { feature } from 'bun:bundle'
import { shouldAutoEnableClaudeInChrome } from 'src/utils/claudeInChrome/setup.js'
import { registerBatchSkill } from './batch.js'
import { shouldEnableClaudeInChromeSkill } from './claudeInChromeAccess.js'
import { registerClaudeInChromeSkill } from './claudeInChrome.js'
import { registerDebugSkill } from './debug.js'
import { registerKeybindingsSkill } from './keybindings.js'
@@ -53,7 +53,7 @@ export function initBundledSkills(): void {
/* eslint-enable @typescript-eslint/no-require-imports */
registerClaudeApiSkill()
}
if (shouldAutoEnableClaudeInChrome()) {
if (shouldEnableClaudeInChromeSkill()) {
registerClaudeInChromeSkill()
}
if (feature('RUN_SKILL_GENERATOR')) {
-13
View File
@@ -3,7 +3,6 @@ import {
setSessionBypassPermissionsMode,
setSessionDangerousPermissionMode,
} from '../bootstrap/state.js'
import { isAntEmployee } from '../utils/buildConfig.js'
import {
clearApiKeyHelperCache,
clearAwsCredentialsCache,
@@ -160,18 +159,6 @@ export function onChangeAppState({
}))
}
// tungstenPanelVisible (internal-only tmux panel sticky toggle)
if (isAntEmployee()) {
if (
newState.tungstenPanelVisible !== oldState.tungstenPanelVisible &&
newState.tungstenPanelVisible !== undefined &&
getGlobalConfig().tungstenPanelVisible !== newState.tungstenPanelVisible
) {
const tungstenPanelVisible = newState.tungstenPanelVisible
saveGlobalConfig(current => ({ ...current, tungstenPanelVisible }))
}
}
// settings: clear auth-related caches when settings change
// This ensures apiKeyHelper and AWS/GCP credential changes take effect immediately
if (newState.settings !== oldState.settings) {
+149 -1
View File
@@ -1,5 +1,15 @@
import { describe, expect, test } from 'bun:test'
import { inputSchema } from './AgentTool.js'
import {
AgentTool,
assertAgentToolCwdAllowed,
fullInputSchema,
inputSchema,
outputSchema,
resolveAgentToolCwdOverride,
resolveAgentToolEffectiveIsolation,
} from './AgentTool.js'
import { renderToolResultMessage } from './UI.js'
import { renderToString } from '../../utils/staticRender.js'
const baseInput = {
description: 'Run check',
@@ -67,3 +77,141 @@ describe('AgentTool input schema model override', () => {
expect(description).toContain('inherit')
})
})
describe('AgentTool input schema isolation contract', () => {
test('accepts worktree isolation with the base required fields', () => {
expect(
inputSchema().safeParse({ ...baseInput, isolation: 'worktree' }).success,
).toBe(true)
})
test('rejects the removed remote isolation value', () => {
expect(
inputSchema().safeParse({ ...baseInput, isolation: 'remote' }).success,
).toBe(false)
})
test('rejects cwd together with worktree isolation in the full schema', () => {
expect(
fullInputSchema().safeParse({
...baseInput,
isolation: 'worktree',
cwd: '/tmp/openclaude-agent',
}).success,
).toBe(false)
})
test('accepts cwd without worktree isolation in the full schema', () => {
expect(
fullInputSchema().safeParse({
...baseInput,
cwd: '/tmp/openclaude-agent',
}).success,
).toBe(true)
})
test('inherits worktree isolation from agent definitions', () => {
expect(resolveAgentToolEffectiveIsolation(undefined, 'worktree')).toBe(
'worktree',
)
expect(resolveAgentToolEffectiveIsolation('worktree', undefined)).toBe(
'worktree',
)
expect(resolveAgentToolEffectiveIsolation(undefined, undefined)).toBe(
undefined,
)
})
test('rejects cwd for any effective worktree isolation source', () => {
expect(() =>
assertAgentToolCwdAllowed('/tmp/openclaude-agent', 'worktree'),
).toThrow('cwd is mutually exclusive with isolation: "worktree".')
expect(() =>
assertAgentToolCwdAllowed('/tmp/openclaude-agent', undefined),
).not.toThrow()
})
test('prefers worktree cwd over explicit cwd when both are present defensively', () => {
expect(
resolveAgentToolCwdOverride('/tmp/openclaude-agent', {
worktreePath: '/tmp/openclaude-worktree',
}),
).toBe('/tmp/openclaude-worktree')
expect(resolveAgentToolCwdOverride('/tmp/openclaude-agent', null)).toBe(
'/tmp/openclaude-agent',
)
})
})
describe('AgentTool output status contract', () => {
test('rejects removed remote-launched output status', () => {
expect(
outputSchema().safeParse({
status: 'remote_launched',
prompt: baseInput.prompt,
sessionUrl: 'https://example.com/session',
}).success,
).toBe(false)
})
test('maps async-launched output to the expected tool result text', () => {
const block = AgentTool.mapToolResultToToolResultBlockParam(
{
status: 'async_launched',
agentId: 'agent-1',
description: baseInput.description,
prompt: baseInput.prompt,
outputFile: '/tmp/openclaude-agent-output.txt',
canReadOutputFile: true,
},
'toolu_1',
)
expect(block.type).toBe('tool_result')
const text = block.content[0]?.type === 'text' ? block.content[0].text : ''
expect(text).toContain('Async agent launched successfully')
expect(text).toContain('output_file: /tmp/openclaude-agent-output.txt')
})
test('throws for unsupported output statuses', () => {
expect(() =>
AgentTool.mapToolResultToToolResultBlockParam(
{ status: 'remote_launched' } as never,
'toolu_1',
),
).toThrow('Unexpected agent tool result status: remote_launched')
})
test('renders async-launched output as a backgrounded agent', async () => {
const output = await renderToString(
renderToolResultMessage(
{
status: 'async_launched',
agentId: 'agent-1',
description: baseInput.description,
prompt: baseInput.prompt,
outputFile: '/tmp/openclaude-agent-output.txt',
canReadOutputFile: true,
},
[],
{ tools: [], verbose: false, theme: 'dark' },
),
80,
)
expect(output).toContain('Backgrounded agent')
})
test('does not render the removed remote-launched status', async () => {
const output = await renderToString(
renderToolResultMessage(
{ status: 'remote_launched' } as never,
[],
{ tools: [], verbose: false, theme: 'dark' },
),
80,
)
expect(output.trim()).toBe('')
})
})
+46 -99
View File
@@ -13,7 +13,6 @@ import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEve
import { clearDumpState } from '../../services/api/dumpPrompts.js';
import { resolveAgentRunModelRouting, resolveOutOfProcessTeammateProvider } from '../../services/api/agentRouting.js';
import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js';
import { assembleToolPool } from '../../tools.js';
import { asAgentId } from '../../types/ids.js';
import { runWithAgentContext } from '../../utils/agentContext.js';
@@ -39,7 +38,6 @@ import { asSystemPrompt } from '../../utils/systemPromptType.js';
import { getTaskOutputPath } from '../../utils/task/diskOutput.js';
import { getParentSessionId, isTeammate } from '../../utils/teammate.js';
import { isInProcessTeammate } from '../../utils/teammateContext.js';
import { teleportToRemote } from '../../utils/teleport.js';
import { getAssistantMessageContentLength } from '../../utils/tokens.js';
import { createAgentId } from '../../utils/uuid.js';
import { createAgentWorktree, hasWorktreeChanges, removeAgentWorktree } from '../../utils/worktree.js';
@@ -91,7 +89,7 @@ const baseInputSchema = lazySchema(() => z.object({
}));
// Full schema combining base + multi-agent params + isolation
const fullInputSchema = lazySchema(() => {
export const fullInputSchema = lazySchema(() => {
// Multi-agent parameters
const multiAgentInputSchema = z.object({
name: z.string().optional().describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'),
@@ -99,8 +97,11 @@ const fullInputSchema = lazySchema(() => {
mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).')
});
return baseInputSchema().merge(multiAgentInputSchema).extend({
isolation: (isAntEmployee() ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])).optional().describe(isAntEmployee() ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'),
isolation: z.enum(['worktree']).optional().describe('Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'),
cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".')
}).refine(input => !(input.isolation === 'worktree' && input.cwd !== undefined), {
path: ['cwd'],
message: 'cwd is mutually exclusive with isolation: "worktree".'
});
});
@@ -136,9 +137,38 @@ type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
name?: string;
team_name?: string;
mode?: z.infer<ReturnType<typeof permissionModeSchema>>;
isolation?: 'worktree' | 'remote';
isolation?: 'worktree';
cwd?: string;
};
type AgentToolIsolation = AgentToolInput['isolation'];
type AgentToolWorktreeInfo = {
worktreePath: string;
} | null | undefined;
export function resolveAgentToolEffectiveIsolation(
requestedIsolation: AgentToolIsolation,
agentIsolation: AgentToolIsolation,
): AgentToolIsolation {
return requestedIsolation === 'worktree' || agentIsolation === 'worktree'
? 'worktree'
: undefined;
}
export function assertAgentToolCwdAllowed(
cwd: string | undefined,
effectiveIsolation: AgentToolIsolation,
): void {
if (cwd !== undefined && effectiveIsolation === 'worktree') {
throw new Error('cwd is mutually exclusive with isolation: "worktree".');
}
}
export function resolveAgentToolCwdOverride(
cwd: string | undefined,
worktreeInfo: AgentToolWorktreeInfo,
): string | undefined {
return worktreeInfo?.worktreePath ?? cwd;
}
// Output schema - multi-agent spawned schema added dynamically at runtime when enabled
export const outputSchema = lazySchema(() => {
@@ -180,20 +210,8 @@ type TeammateSpawnedOutput = {
// Combined output type including both public and internal types
// Note: TeammateSpawnedOutput type is fine - TypeScript types are erased at compile time
// Private type for remote-launched results — excluded from exported schema
// like TeammateSpawnedOutput for dead code elimination purposes. Exported
// for UI.tsx to do proper discriminated-union narrowing instead of ad-hoc casts.
export type RemoteLaunchedOutput = {
status: 'remote_launched';
taskId: string;
sessionUrl: string;
description: string;
prompt: string;
outputFile: string;
};
type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput;
type InternalOutput = Output | TeammateSpawnedOutput;
import type { AgentToolProgress, ShellProgress } from '../../types/tools.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
// AgentTool forwards both its own progress events and shell progress
// events from the sub-agent so the SDK receives tool_progress updates during bash/powershell runs.
export type Progress = AgentToolProgress | ShellProgress;
@@ -461,59 +479,13 @@ export const AgentTool = buildTool({
is_fork: isForkPath
});
// Resolve effective isolation mode (explicit param overrides agent def)
const effectiveIsolation = isolation ?? selectedAgent.isolation;
// Remote isolation: delegate to CCR. Gated internal-only — the guard enables
// dead code elimination of the entire block for external builds.
if (isAntEmployee() && effectiveIsolation === 'remote') {
const eligibility = await checkRemoteAgentEligibility();
if (!eligibility.eligible) {
const reasons = eligibility.errors.map(formatPreconditionError).join('\n');
throw new Error(`Cannot launch remote agent:\n${reasons}`);
}
let bundleFailHint: string | undefined;
const session = await teleportToRemote({
initialMessage: prompt,
description,
signal: toolUseContext.abortController.signal,
onBundleFail: msg => {
bundleFailHint = msg;
}
});
if (!session) {
throw new Error(bundleFailHint ?? 'Failed to create remote session');
}
const {
taskId,
sessionId
} = registerRemoteAgentTask({
remoteTaskType: 'remote-agent',
session: {
id: session.id,
title: session.title || description
},
command: prompt,
context: toolUseContext,
toolUseId: toolUseContext.toolUseId
});
logEvent('tengu_agent_tool_remote_launched', {
agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
const remoteResult: RemoteLaunchedOutput = {
status: 'remote_launched',
taskId,
sessionUrl: getRemoteTaskSessionUrl(sessionId),
description,
prompt,
outputFile: getTaskOutputPath(taskId)
};
return {
data: remoteResult
} as unknown as {
data: Output;
};
}
// Agent frontmatter can force worktree isolation too, so validate cwd
// against the effective mode instead of only the raw tool input.
const effectiveIsolation = resolveAgentToolEffectiveIsolation(
isolation,
selectedAgent.isolation,
);
assertAgentToolCwdAllowed(cwd, effectiveIsolation);
// System prompt + prompt messages: branch on fork path.
//
// Fork path: child inherits the PARENT's system prompt (not FORK_AGENT's)
@@ -556,9 +528,6 @@ export const AgentTool = buildTool({
// Log agent memory loaded event for subagents
if (selectedAgent.memory) {
logEvent('tengu_agent_memory_loaded', {
...(isAntEmployee() && {
agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
scope: selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source: 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
@@ -682,9 +651,9 @@ export const AgentTool = buildTool({
agentName: name,
};
// Helper to wrap execution with a cwd override: explicit cwd arg (KAIROS)
// takes precedence over worktree isolation path.
const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath;
// Helper to wrap execution with a cwd override. Worktree wins if present;
// cwd is rejected for worktree isolation above, but keep this defensive.
const cwdOverridePath = resolveAgentToolCwdOverride(cwd, worktreeInfo);
const wrapWithCwd = <T,>(fn: () => T): T => cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn();
// Helper to clean up worktree after agent completes
@@ -1328,17 +1297,6 @@ export const AgentTool = buildTool({
return input?.description ?? 'Running task';
},
async checkPermissions(input, context): Promise<PermissionResult> {
const appState = context.getAppState();
// Only route through auto mode classifier when in auto mode
// In all other modes, auto-approve sub-agent generation
// Note: isAntEmployee() guard enables dead code elimination for external builds
if (isAntEmployee() && appState.toolPermissionContext.mode === 'auto') {
return {
behavior: 'passthrough',
message: 'Agent tool requires permission to spawn sub-agents.'
};
}
return {
behavior: 'allow',
updatedInput: input
@@ -1362,17 +1320,6 @@ The agent is now running and will receive instructions via mailbox.`
}]
};
}
if ('status' in internalData && internalData.status === 'remote_launched') {
const r = internalData;
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [{
type: 'text',
text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.`
}]
};
}
if (data.status === 'async_launched') {
const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`;
const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Briefly tell the user what you launched and end your response — agent results will arrive in a subsequent message. You may continue first ONLY if you have other tasks on clearly different files that this agent is not touching.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`;
+7 -119
View File
@@ -14,22 +14,19 @@ import { Message as MessageComponent } from '../../components/Message.js';
import { MessageResponse } from '../../components/MessageResponse.js';
import { ToolUseLoader } from '../../components/ToolUseLoader.js';
import { Box, Text } from '../../ink.js';
import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js';
import { findToolByName, type Tools } from '../../Tool.js';
import type { Message, ProgressMessage } from '../../types/message.js';
import type { AgentToolProgress } from '../../types/tools.js';
import { count } from '../../utils/array.js';
import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js';
import { getDisplayPath } from '../../utils/file.js';
import { formatDuration, formatNumber } from '../../utils/format.js';
import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js';
import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js';
import type { Theme, ThemeName } from '../../utils/theme.js';
import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js';
import type { outputSchema, Progress } from './AgentTool.js';
import { inputSchema } from './AgentTool.js';
import { getAgentColor } from './agentColorManager.js';
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
const MAX_PROGRESS_MESSAGES_TO_SHOW = 3;
/**
@@ -92,91 +89,11 @@ type ProcessedMessage = {
message: ProgressMessage<AgentToolProgress>;
} | SummaryMessage;
/**
* Process progress messages to group consecutive search/read operations into summaries.
* For ants only - returns original messages for non-ants.
* @param isAgentRunning - If true, the last group is always marked as active (in progress)
*/
function processProgressMessages(messages: ProgressMessage<Progress>[], tools: Tools, isAgentRunning: boolean): ProcessedMessage[] {
// Only process for ants
if (!isAntEmployee()) {
return messages.filter((m): m is ProgressMessage<AgentToolProgress> => hasProgressMessage(m.data) && m.data.message.type !== 'user').map(m => ({
type: 'original',
message: m
}));
}
const result: ProcessedMessage[] = [];
let currentGroup: {
searchCount: number;
readCount: number;
replCount: number;
startUuid: string;
} | null = null;
function flushGroup(isActive: boolean): void {
if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) {
result.push({
type: 'summary',
searchCount: currentGroup.searchCount,
readCount: currentGroup.readCount,
replCount: currentGroup.replCount,
uuid: `summary-${currentGroup.startUuid}`,
isActive
});
}
currentGroup = null;
}
const agentMessages = messages.filter((m): m is ProgressMessage<AgentToolProgress> => hasProgressMessage(m.data));
// Build tool_use lookup incrementally as we iterate
const toolUseByID = new Map<string, ToolUseBlockParam>();
for (const msg of agentMessages) {
// Track tool_use blocks as we see them
if (msg.data.message.type === 'assistant') {
for (const c of msg.data.message.message.content) {
if (c.type === 'tool_use') {
toolUseByID.set(c.id, c as ToolUseBlockParam);
}
}
}
const info = getSearchOrReadInfo(msg, tools, toolUseByID);
if (info && (info.isSearch || info.isRead || info.isREPL)) {
// This is a search/read/REPL operation - add to current group
if (!currentGroup) {
currentGroup = {
searchCount: 0,
readCount: 0,
replCount: 0,
startUuid: msg.uuid
};
}
// Only count tool_result messages (not tool_use) to avoid double counting
if (msg.data.message.type === 'user') {
if (info.isSearch) {
currentGroup.searchCount++;
} else if (info.isREPL) {
currentGroup.replCount++;
} else if (info.isRead) {
currentGroup.readCount++;
}
}
} else {
// Non-search/read/REPL message - flush current group (completed) and add this message
flushGroup(false);
// Skip user tool_result messages — subagent progress messages lack
// toolUseResult, so UserToolSuccessMessage returns null and the
// height=1 Box in renderToolUseProgressMessage shows as a blank line.
if (msg.data.message.type !== 'user') {
result.push({
type: 'original',
message: msg
});
}
}
}
// Flush any remaining group - it's active if the agent is still running
flushGroup(isAgentRunning);
return result;
function processProgressMessages(messages: ProgressMessage<Progress>[], _tools: Tools, _isAgentRunning: boolean): ProcessedMessage[] {
return messages.filter((m): m is ProgressMessage<AgentToolProgress> => hasProgressMessage(m.data) && m.data.message.type !== 'user').map(m => ({
type: 'original',
message: m
}));
}
const ESTIMATED_LINES_PER_TOOL = 9;
const TERMINAL_BUFFER_LINES = 7;
@@ -323,21 +240,6 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage
theme: ThemeName;
isTranscriptMode?: boolean;
}): React.ReactNode {
// Remote-launched agents (internal-only) use a private output type not in the
// public schema. Narrow via the internal discriminant.
const internal = data as Output | RemoteLaunchedOutput;
if (internal.status === 'remote_launched') {
return <Box flexDirection="column">
<MessageResponse height={1}>
<Text>
Remote agent launched{' '}
<Text dimColor>
· {internal.taskId} · {internal.sessionUrl}
</Text>
</Text>
</MessageResponse>
</Box>;
}
if (data.status === 'async_launched') {
const {
prompt
@@ -365,7 +267,6 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage
return null;
}
const {
agentId,
totalDurationMs,
totalToolUseCount,
totalTokens,
@@ -385,11 +286,6 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage
}
});
return <Box flexDirection="column">
{isAntEmployee() && <MessageResponse>
<Text color="warning">
[internal] API calls: {getDisplayPath(getDumpPromptsPath(agentId))}
</Text>
</MessageResponse>}
{isTranscriptMode && prompt && <MessageResponse>
<AgentPromptDisplay prompt={prompt} theme={theme} />
</MessageResponse>}
@@ -587,15 +483,7 @@ export function renderToolUseRejectedMessage(_input: {
verbose: boolean;
isTranscriptMode?: boolean;
}): React.ReactNode {
// Get agentId from progress messages if available (agent was running before rejection)
const firstData = progressMessagesForMessage[0]?.data;
const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined;
return <>
{isAntEmployee() && agentId && <MessageResponse>
<Text color="warning">
[internal] API calls: {getDisplayPath(getDumpPromptsPath(agentId))}
</Text>
</MessageResponse>}
{renderToolUseProgressMessage(progressMessagesForMessage, {
tools,
verbose,
@@ -706,7 +594,7 @@ export function renderGroupedAgentToolUse(toolUses: Array<{
const outputStatus = (result?.output as {
status?: string;
} | undefined)?.status;
const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched';
const backgroundedMidExecution = outputStatus === 'async_launched';
const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn;
const name = parsedInput.success ? parsedInput.data.name : undefined;
return {
+37 -1
View File
@@ -17,6 +17,7 @@ const originalEnv = {
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
CLAUDE_CODE_USE_NATIVE_FILE_SEARCH:
process.env.CLAUDE_CODE_USE_NATIVE_FILE_SEARCH,
USER_TYPE: process.env.USER_TYPE,
}
let tempDir: string
@@ -37,6 +38,7 @@ afterEach(async () => {
restoreEnv('CLAUDE_CONFIG_DIR')
restoreEnv('CLAUDE_CODE_SIMPLE')
restoreEnv('CLAUDE_CODE_USE_NATIVE_FILE_SEARCH')
restoreEnv('USER_TYPE')
clearAgentDefinitionsCache()
loadMarkdownFilesForSubdir.cache.clear?.()
} finally {
@@ -57,6 +59,7 @@ async function writeAgent(
filePath: string,
name: string,
prompt = `You are ${name}.`,
extraFrontmatter = '',
): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(
@@ -64,6 +67,7 @@ async function writeAgent(
`---
name: ${name}
description: "Use for regression coverage"
${extraFrontmatter}
---
${prompt}
@@ -119,6 +123,38 @@ describe('agent definition loading', () => {
const { activeAgents } = await getAgentDefinitionsWithOverrides(projectDir)
const agent = activeAgents.find(agent => agent.agentType === 'shared-agent')
expect(agent?.getSystemPrompt()).toBe('openclaude prompt')
expect(agent?.source === 'projectSettings' ? agent.getSystemPrompt() : undefined).toBe('openclaude prompt')
})
test('accepts worktree isolation in markdown agent frontmatter', async () => {
const projectDir = join(tempDir, 'project')
await writeAgent(
join(projectDir, '.openclaude', 'agents', 'worktree-agent.md'),
'worktree-agent',
'worktree prompt',
'isolation: worktree\n',
)
const { activeAgents } = await getAgentDefinitionsWithOverrides(projectDir)
const agent = activeAgents.find(agent => agent.agentType === 'worktree-agent')
expect(agent?.isolation).toBe('worktree')
})
test('rejects removed remote isolation in markdown agent frontmatter', async () => {
process.env.USER_TYPE = 'ant'
const projectDir = join(tempDir, 'project')
await writeAgent(
join(projectDir, '.openclaude', 'agents', 'remote-agent.md'),
'remote-agent',
'remote prompt',
'isolation: remote\n',
)
const { activeAgents } = await getAgentDefinitionsWithOverrides(projectDir)
const agent = activeAgents.find(agent => agent.agentType === 'remote-agent')
expect(agent).toBeDefined()
expect(agent?.isolation).toBeUndefined()
})
})
+5 -9
View File
@@ -90,10 +90,7 @@ const AgentJsonSchema = lazySchema(() =>
initialPrompt: z.string().optional(),
memory: z.enum(['user', 'project', 'local']).optional(),
background: z.boolean().optional(),
isolation: (process.env.USER_TYPE === 'ant'
? z.enum(['worktree', 'remote'])
: z.enum(['worktree'])
).optional(),
isolation: z.enum(['worktree']).optional(),
}),
)
@@ -122,7 +119,7 @@ export type BaseAgentDefinition = {
background?: boolean // Always run as background task when spawned
initialPrompt?: string // Prepended to the first user turn (slash commands work)
memory?: AgentMemoryScope // Persistent memory scope
isolation?: 'worktree' | 'remote' // Run in an isolated git worktree, or remotely in CCR (internal-only)
isolation?: 'worktree' // Run in an isolated git worktree
pendingSnapshotUpdate?: { snapshotTimestamp: string }
/** Omit CLAUDE.md hierarchy from the agent's userContext. Read-only agents
* (Explore, Plan) don't need commit/PR/lint guidelines the main agent has
@@ -594,10 +591,9 @@ export function parseAgentFromMarkdown(
}
}
// Parse isolation mode. 'remote' is internal-only; external builds reject it at parse time.
type IsolationMode = 'worktree' | 'remote'
const VALID_ISOLATION_MODES: readonly IsolationMode[] =
process.env.USER_TYPE === 'ant' ? ['worktree', 'remote'] : ['worktree']
// Parse isolation mode.
type IsolationMode = 'worktree'
const VALID_ISOLATION_MODES: readonly IsolationMode[] = ['worktree']
const isolationRaw = frontmatter['isolation'] as string | undefined
let isolation: IsolationMode | undefined
if (isolationRaw !== undefined) {
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import { getPrompt } from './prompt.js'
import type { AgentDefinition } from './loadAgentsDir.js'
const originalEnv = {
CLAUDE_CODE_AGENT_LIST_IN_MESSAGES:
process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES,
USER_TYPE: process.env.USER_TYPE,
}
beforeEach(async () => {
await acquireSharedMutationLock('tools/AgentTool/prompt.test.ts')
})
afterEach(() => {
try {
restoreEnv('CLAUDE_CODE_AGENT_LIST_IN_MESSAGES')
restoreEnv('USER_TYPE')
} finally {
releaseSharedMutationLock()
}
})
function restoreEnv(key: keyof typeof originalEnv): void {
const originalValue = originalEnv[key]
if (originalValue === undefined) {
delete process.env[key]
} else {
process.env[key] = originalValue
}
}
const agents: AgentDefinition[] = [
{
agentType: 'general-purpose',
whenToUse: 'Use for general tasks',
source: 'projectSettings',
getSystemPrompt: () => 'system prompt',
},
]
describe('AgentTool prompt isolation contract', () => {
test('advertises worktree isolation but never remote isolation', async () => {
process.env.USER_TYPE = 'ant'
process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES = 'false'
const prompt = await getPrompt(agents)
expect(prompt).toContain('isolation: "worktree"')
expect(prompt).not.toContain('isolation: "remote"')
})
})
-4
View File
@@ -262,10 +262,6 @@ Usage notes:
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple ${AGENT_TOOL_NAME} tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result.${
process.env.USER_TYPE === 'ant'
? `\n- You can set \`isolation: "remote"\` to run the agent in a remote CCR environment. This is always a background task; you'll be notified when it completes. Use for long-running tasks that need a fresh sandbox.`
: ''
}${
isInProcessTeammate()
? `
- The run_in_background, name, team_name, and mode parameters are not available in this context. Only synchronous subagents are supported.`
+1 -2
View File
@@ -27,7 +27,6 @@ import type { ThemeName } from '../../utils/theme.js';
import { AgentPromptDisplay, AgentResponseDisplay } from '../AgentTool/UI.js';
import BashToolResultMessage from '../BashTool/BashToolResultMessage.js';
import { TASK_OUTPUT_TOOL_NAME } from './constants.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
const inputSchema = lazySchema(() => z.strictObject({
task_id: z.string().describe('The task ID to get output from'),
block: semanticBoolean(z.boolean().default(true)).describe('Whether to wait for completion'),
@@ -162,7 +161,7 @@ export const TaskOutputTool: Tool<InputSchema, TaskOutputToolOutput> = buildTool
return this.isReadOnly?.(_input) ?? false;
},
isEnabled() {
return !isAntEmployee();
return true;
},
isReadOnly(_input) {
return true;
-4
View File
@@ -4,7 +4,6 @@ import { stringWidth } from '../../ink/stringWidth.js';
import { Text } from '../../ink.js';
import { truncateToWidthNoEllipsis } from '../../utils/format.js';
import type { Output } from './TaskStopTool.js';
import { isAntEmployee } from '../../utils/buildConfig.js';
export function renderToolUseMessage(): React.ReactNode {
return '';
}
@@ -26,9 +25,6 @@ export function renderToolResultMessage(output: Output, _progressMessagesForMess
}: {
verbose: boolean;
}): React.ReactNode {
if (isAntEmployee()) {
return null;
}
const rawCommand = output.command ?? '';
const command = verbose ? rawCommand : truncateCommand(rawCommand);
const suffix = command !== rawCommand ? '… · stopped' : ' · stopped';
+1 -21
View File
@@ -4,7 +4,6 @@ import { useEffect, useRef } from 'react';
import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js';
import { Box, Text } from '../ink.js';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import { isAntEmployee } from './buildConfig.js';
type Props = {
onRun: () => void;
onCancel: () => void;
@@ -77,33 +76,14 @@ export function AutoRunIssueNotification(t0) {
}
export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good';
/**
* Determines if /issue should auto-run for Ant users
*/
export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
// Only for Ant users
if (!isAntEmployee()) {
return false;
}
switch (reason) {
case 'feedback_survey_bad':
return false;
case 'feedback_survey_good':
return false;
default:
return false;
}
return false;
}
/**
* Returns the appropriate command to auto-run based on the reason
* internal-only: good-claude command only exists in ant builds
*/
export function getAutoRunCommand(reason: AutoRunIssueReason): string {
// Only ant builds have the /good-claude command
if (isAntEmployee() && reason === 'feedback_survey_good') {
return '/good-claude';
}
return '/issue';
}
-39
View File
@@ -1,39 +0,0 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { isAntEmployee } from './buildConfig.ts'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
// Finding #42-2: process.env.USER_TYPE === 'ant' is checked directly in multiple
// places, allowing any external user to activate Anthropic-internal code paths.
// In OpenClaude, this must always be false regardless of env var.
let originalUserType: string | undefined
beforeEach(async () => {
await acquireSharedMutationLock('utils/buildConfig.test.ts')
originalUserType = process.env.USER_TYPE
})
afterEach(() => {
try {
if (originalUserType === undefined) {
delete process.env.USER_TYPE
} else {
process.env.USER_TYPE = originalUserType
}
} finally {
releaseSharedMutationLock()
}
})
test('isAntEmployee always returns false in OpenClaude regardless of USER_TYPE env var', () => {
process.env.USER_TYPE = 'ant'
expect(isAntEmployee()).toBe(false)
})
test('isAntEmployee returns false even when USER_TYPE is unset', () => {
delete process.env.USER_TYPE
expect(isAntEmployee()).toBe(false)
})
-25
View File
@@ -1,25 +0,0 @@
/**
* OpenClaude build-time constants.
*
* These replace process.env checks that were only meaningful in the upstream
* internal build. In OpenClaude all such gates are permanently disabled so
* external users cannot activate internal code paths by setting env vars.
*/
/**
* Always false in OpenClaude.
* Replaces all `process.env.USER_TYPE === 'ant'` checks so that no external
* user can activate internal-only features (commit attribution hooks,
* system-prompt section clearing, dangerously-skip-permissions bypass, etc.)
* by setting USER_TYPE in their shell environment.
*
* Exported as a const so bundlers can evaluate it at build time and
* eliminate dead branches (dynamic imports, etc.) that are gated behind
* this check.
*/
export const IS_ANT_EMPLOYEE = false as const
/** @deprecated Use IS_ANT_EMPLOYEE for build-time DCE; this function is kept for call-site convenience. */
export function isAntEmployee(): boolean {
return IS_ANT_EMPLOYEE
}
+1 -2
View File
@@ -77,8 +77,7 @@ export function shouldAutoEnableClaudeInChrome(): boolean {
shouldAutoEnable =
getIsInteractive() &&
isChromeExtensionInstalled_CACHED_MAY_BE_STALE() &&
(process.env.USER_TYPE === 'ant' ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_chrome_auto_enable', false))
getFeatureValue_CACHED_MAY_BE_STALE('tengu_chrome_auto_enable', false)
return shouldAutoEnable
}
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, test } from 'bun:test'
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
import {
CLAUDE_IN_CHROME_SKILL_HINT,
CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER,
} from './prompt.js'
import {
mergeClaudeInChromeStartupConfig,
resolveClaudeInChromeStartupMode,
} from './startup.js'
const existingMcpConfig: Record<string, ScopedMcpServerConfig> = {
existing: {
type: 'stdio',
command: 'existing-command',
args: [],
scope: 'dynamic',
},
}
const setupResult = {
mcpConfig: {
'claude-in-chrome': {
type: 'stdio' as const,
command: 'chrome-command',
args: ['--chrome'],
scope: 'dynamic' as const,
},
},
allowedTools: ['mcp__claude-in-chrome__tabs_context_mcp'],
systemPrompt: 'chrome system prompt',
}
describe('resolveClaudeInChromeStartupMode', () => {
test('uses explicit Chrome startup only when subscriber access is available', () => {
expect(
resolveClaudeInChromeStartupMode({
explicitEnabled: true,
autoEnabled: false,
hasClaudeInChromeAccess: true,
}),
).toBe('explicit')
expect(
resolveClaudeInChromeStartupMode({
explicitEnabled: true,
autoEnabled: false,
hasClaudeInChromeAccess: false,
}),
).toBe('disabled')
})
test('uses auto Chrome startup only when subscriber access is available', () => {
expect(
resolveClaudeInChromeStartupMode({
explicitEnabled: false,
autoEnabled: true,
hasClaudeInChromeAccess: true,
}),
).toBe('auto')
expect(
resolveClaudeInChromeStartupMode({
explicitEnabled: false,
autoEnabled: true,
hasClaudeInChromeAccess: false,
}),
).toBe('disabled')
})
test('prefers explicit startup over auto startup', () => {
expect(
resolveClaudeInChromeStartupMode({
explicitEnabled: true,
autoEnabled: true,
hasClaudeInChromeAccess: true,
}),
).toBe('explicit')
})
})
describe('mergeClaudeInChromeStartupConfig', () => {
test('explicit startup merges MCP config, allowed tools, and prepends the Chrome system prompt', () => {
const merged = mergeClaudeInChromeStartupConfig({
mode: 'explicit',
setupResult,
dynamicMcpConfig: existingMcpConfig,
appendSystemPrompt: 'existing prompt',
hasWebBrowserTool: false,
})
expect(Object.keys(merged.dynamicMcpConfig)).toEqual([
'existing',
'claude-in-chrome',
])
expect(merged.allowedTools).toEqual(setupResult.allowedTools)
expect(merged.appendSystemPrompt).toBe(
'chrome system prompt\n\nexisting prompt',
)
})
test('auto startup merges MCP config and appends the Chrome skill hint only for subscribers', () => {
const merged = mergeClaudeInChromeStartupConfig({
mode: 'auto',
setupResult,
dynamicMcpConfig: existingMcpConfig,
appendSystemPrompt: 'existing prompt',
hasWebBrowserTool: false,
})
expect(Object.keys(merged.dynamicMcpConfig)).toEqual([
'existing',
'claude-in-chrome',
])
expect(merged.allowedTools).toEqual([])
expect(merged.appendSystemPrompt).toBe(
`existing prompt\n\n${CLAUDE_IN_CHROME_SKILL_HINT}`,
)
})
test('auto startup uses the WebBrowser-specific hint when that tool is available', () => {
const merged = mergeClaudeInChromeStartupConfig({
mode: 'auto',
setupResult,
dynamicMcpConfig: {},
hasWebBrowserTool: true,
})
expect(merged.appendSystemPrompt).toBe(
CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER,
)
})
})
+76
View File
@@ -0,0 +1,76 @@
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
import {
CLAUDE_IN_CHROME_SKILL_HINT,
CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER,
} from './prompt.js'
import type { setupClaudeInChrome } from './setup.js'
type ClaudeInChromeSetupResult = ReturnType<typeof setupClaudeInChrome>
export type ClaudeInChromeStartupMode = 'disabled' | 'explicit' | 'auto'
export function resolveClaudeInChromeStartupMode({
explicitEnabled,
autoEnabled,
hasClaudeInChromeAccess,
}: {
explicitEnabled: boolean
autoEnabled: boolean
hasClaudeInChromeAccess: boolean
}): ClaudeInChromeStartupMode {
if (!hasClaudeInChromeAccess) {
return 'disabled'
}
if (explicitEnabled) {
return 'explicit'
}
if (autoEnabled) {
return 'auto'
}
return 'disabled'
}
export function mergeClaudeInChromeStartupConfig({
mode,
setupResult,
dynamicMcpConfig,
appendSystemPrompt,
hasWebBrowserTool,
}: {
mode: Exclude<ClaudeInChromeStartupMode, 'disabled'>
setupResult: ClaudeInChromeSetupResult
dynamicMcpConfig: Record<string, ScopedMcpServerConfig>
appendSystemPrompt?: string
hasWebBrowserTool: boolean
}): {
dynamicMcpConfig: Record<string, ScopedMcpServerConfig>
allowedTools: string[]
appendSystemPrompt?: string
} {
const nextDynamicMcpConfig = {
...dynamicMcpConfig,
...setupResult.mcpConfig,
}
if (mode === 'explicit') {
return {
dynamicMcpConfig: nextDynamicMcpConfig,
allowedTools: setupResult.allowedTools,
appendSystemPrompt: appendSystemPrompt
? `${setupResult.systemPrompt}\n\n${appendSystemPrompt}`
: setupResult.systemPrompt,
}
}
const hint = hasWebBrowserTool
? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER
: CLAUDE_IN_CHROME_SKILL_HINT
return {
dynamicMcpConfig: nextDynamicMcpConfig,
allowedTools: [],
appendSystemPrompt: appendSystemPrompt
? `${appendSystemPrompt}\n\n${hint}`
: hint,
}
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, test } from 'bun:test'
import { isFallbackAgentLaunchSuccessStatus } from './hooks.js'
describe('fallback agent hook launch statuses', () => {
test('accepts only current AgentTool success statuses', () => {
expect(isFallbackAgentLaunchSuccessStatus('async_launched')).toBe(true)
expect(isFallbackAgentLaunchSuccessStatus('completed')).toBe(true)
expect(isFallbackAgentLaunchSuccessStatus('teammate_spawned')).toBe(true)
expect(isFallbackAgentLaunchSuccessStatus('remote_launched')).toBe(false)
expect(isFallbackAgentLaunchSuccessStatus(undefined)).toBe(false)
})
})
+20 -6
View File
@@ -218,6 +218,25 @@ function normalizeFallbackAgentModel(
return undefined
}
const fallbackAgentLaunchSuccessStatuses = [
'async_launched',
'completed',
'teammate_spawned',
] as const
type FallbackAgentLaunchSuccessStatus =
(typeof fallbackAgentLaunchSuccessStatuses)[number]
const fallbackAgentLaunchSuccessStatusSet = new Set<string>(
fallbackAgentLaunchSuccessStatuses,
)
export function isFallbackAgentLaunchSuccessStatus(
status: unknown,
): status is FallbackAgentLaunchSuccessStatus {
return (
typeof status === 'string' && fallbackAgentLaunchSuccessStatusSet.has(status)
)
}
async function launchFallbackAgentFromHookChains(
request: SpawnFallbackAgentRequest,
toolUseContext: ToolUseContext,
@@ -248,12 +267,7 @@ async function launchFallbackAgentFromHookChains(
| undefined
const status = data?.status
if (
status === 'async_launched' ||
status === 'completed' ||
status === 'remote_launched' ||
status === 'teammate_spawned'
) {
if (isFallbackAgentLaunchSuccessStatus(status)) {
return {
launched: true,
agentId: data?.agentId ?? data?.agent_id,
@@ -10,7 +10,6 @@ import { addInvokedSkill, getSessionId } from '../../bootstrap/state.js';
import { COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js';
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js';
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent } from '../../services/analytics/index.js';
import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js';
import { buildPostCompactMessages } from '../../services/compact/compact.js';
import { resetMicrocompactState } from '../../services/compact/microCompact.js';
import type { Progress as AgentProgress } from '../../tools/AgentTool/AgentTool.js';
@@ -23,7 +22,6 @@ import { createAttachmentMessage, getAttachmentMessages } from '../attachments.j
import { logForDebugging } from '../debug.js';
import { isEnvTruthy } from '../envUtils.js';
import { AbortError, MalformedCommandError } from '../errors.js';
import { getDisplayPath } from '../file.js';
import { extractResultText, prepareForkedCommandContext } from '../forkedAgent.js';
import { getFsImplementation } from '../fsOperations.js';
import { isFullscreenEnvEnabled } from '../fullscreen.js';
@@ -46,7 +44,6 @@ import { getAssistantMessageContentLength } from '../tokens.js';
import { createAgentId } from '../uuid.js';
import { getWorkload } from '../workloadContext.js';
import type { ProcessUserInputBaseResult, ProcessUserInputContext } from './processUserInput.js';
import { isAntEmployee } from '../buildConfig.js';
type SlashCommandResult = ProcessUserInputBaseResult & {
command: Command;
};
@@ -273,11 +270,6 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a
let resultText = extractResultText(agentMessages, 'Command completed');
logForDebugging(`Forked slash command /${command.name} completed with agent ${agentId}`);
// Prepend debug log for ant users so it appears inside the command output
if (isAntEmployee()) {
resultText = `[internal] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}\n${resultText}`;
}
// Return the result as a user message (simulates the agent's output)
const messages: UserMessage[] = [createUserMessage({
content: prepareUserContent({
@@ -427,19 +419,7 @@ export async function processSlashCommand(inputString: string, precedingInputBlo
}
logEvent('tengu_input_command', {
...eventData,
invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(isAntEmployee() && {
skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(returnedCommand.type === 'prompt' && {
skill_source: returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
...(returnedCommand.loadedFrom && {
skill_loaded_from: returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
...(returnedCommand.kind && {
skill_kind: returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
})
})
invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
return {
messages: [],
@@ -498,19 +478,7 @@ export async function processSlashCommand(inputString: string, precedingInputBlo
}
logEvent('tengu_input_command', {
...eventData,
invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(isAntEmployee() && {
skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(returnedCommand.type === 'prompt' && {
skill_source: returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
...(returnedCommand.loadedFrom && {
skill_loaded_from: returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}),
...(returnedCommand.kind && {
skill_kind: returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
})
})
invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
// Check if this is a compact result which handle their own synthetic caveat message ordering